From fd6815b7c63ad875101d68153e79cfd1a5e45017 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 16:16:42 +0200 Subject: [PATCH 01/33] feat: implement streaming support for typed generator outputs and add corresponding tests --- docs/emulate_pipeline.md | 417 ++++++++++++++++++ src/OpenHosta/__init__.py | 4 +- src/OpenHosta/core/analizer.py | 59 ++- src/OpenHosta/core/base_model.py | 77 ++++ src/OpenHosta/core/meta_prompt.py | 36 +- src/OpenHosta/exec/ask.py | 231 ++++++---- src/OpenHosta/exec/emulate.py | 78 ++-- src/OpenHosta/models/OpenAICompatible.py | 78 +++- src/OpenHosta/pipelines/simple_pipeline.py | 126 +++++- tests/exec/test_ask_stream.py | 62 +++ tests/exec/test_emulate_generator.py | 87 ++++ tests/functionnal/test_func_ask_stream.py | 33 ++ .../test_func_emulate_generator.py | 48 ++ tests/imagen/test_gen_garden.py | 116 +++++ 14 files changed, 1337 insertions(+), 115 deletions(-) create mode 100644 docs/emulate_pipeline.md create mode 100644 tests/exec/test_ask_stream.py create mode 100644 tests/exec/test_emulate_generator.py create mode 100644 tests/functionnal/test_func_ask_stream.py create mode 100644 tests/functionnal/test_func_emulate_generator.py create mode 100644 tests/imagen/test_gen_garden.py diff --git a/docs/emulate_pipeline.md b/docs/emulate_pipeline.md new file mode 100644 index 00000000..699ebc21 --- /dev/null +++ b/docs/emulate_pipeline.md @@ -0,0 +1,417 @@ +# `emulate()` — Pipeline Reference + +> **Purpose of this document**: Enable a future AI session to quickly understand how `emulate()` converts a Python function into an LLM call and back, without having to re-read all source files. +> +> Last updated: 2026-04-19. Covers the `streaming / generator` extension added in this session. + +--- + +## 0. Entry Points + +| Function | File | Mode | +|---|---|---| +| `emulate()` | `exec/emulate.py` | Sync, single value | +| `emulate_async()` | `exec/emulate.py` | Async, single value | +| `emulate()` (generator caller) | `exec/emulate.py` | Sync generator — `yield emulate()` | +| `emulate_async()` (async-generator caller) | `exec/emulate.py` | Async generator — `yield emulate_async()` | + +`ask()` / `ask_async()` follow a simpler flow (no introspection) and are not covered here. + +--- + +## 1. Stack Inspection — `get_caller_frame` + `get_hosta_inspection` + +**Files**: `core/inspection.py` + +``` +emulate() + └─ get_caller_frame() # sys._getframe(2) → frame of the user function + └─ get_hosta_inspection(frame) + ├─ identify_function_of_frame(frame) + │ walks parent frames / locals / globals to find the function pointer + │ whose __code__ matches the frame's f_code + └─ hosta_analyze(frame, function_pointer) → AnalyzedFunction +``` + +**Key object: `Inspection`** (stored as `function_pointer.hosta_inspection`) + +```python +class Inspection: + function_pointer # the callable (used to cache hosta_inspection on it) + frame # last call frame + analyse # AnalyzedFunction (see below) + logs # dict — filled during pull phase + force_llm_args # dict — merged from inspection + call-site + model # Model instance chosen by pipeline + pipeline # Pipeline instance +``` + +**Key object: `AnalyzedFunction`** + +```python +@dataclass +class AnalyzedFunction: + name: str # function.__name__ + args: List[AnalyzedArgument] # one per parameter, with value from the call frame + type: Any # return type annotation (resolved, not a string) + doc: str # __doc__ +``` + +### Caller-context detection (generator mode — new) + +**File**: `core/caller_context.py` [NEW] + +```python +@dataclass +class CallerContext: + is_async: bool # CO_COROUTINE | CO_ASYNC_GENERATOR flag set + is_generator: bool # CO_GENERATOR | CO_ASYNC_GENERATOR flag set + item_type: Any # inner T from Iterator[T] / AsyncIterator[T] / Generator[T,...] +``` + +`detect_caller_context(frame)` reads `frame.f_code.co_flags` and unwraps the return annotation through `identify_function_of_frame` + `hosta_analyze`. + +--- + +## 2. PUSH phase — Python → LLM messages + +**File**: `pipelines/simple_pipeline.py` — `OneTurnConversationPipeline.push()` + +``` +push(inspection) + ├─ push_detect_missing_types fills in str/Any if annotation missing + ├─ push_choose_model selects Model from model_list by ModelCapabilities + ├─ push_check_uncertainty (only if inside safe() context) injects seed/logprobs + ├─ push_select_meta_prompts picks EMULATE_META_PROMPT + USER_CALL_META_PROMPT + ├─ push_encode_inspected_data calls encode_function() → dict of template variables + └─ push_build_messages renders Jinja2 templates → list[{role, content}] +``` + +### 2a. Template variables produced by `encode_function()` + +**File**: `core/analizer.py` + +| Variable | Source | +|---|---| +| `function_name` | `analyse.name` | +| `function_doc` | `analyse.doc` | +| `function_args` | `name: TypeName` inline string | +| `function_call_arguments` | `name = value` inline string | +| `variables_initialization` | long values placed outside the call | +| `function_return_type` | raw type object | +| `function_return_type_name` | `nice_type_name(analyse.type)` | +| `function_return_as_python_type` | `describe_type_as_python(analyse.type)` — calls `TypeResolver.resolve()` then `str()` on the GuardedType | +| `python_type_definition_dict` | for each arg and return type: fenced `python` block with the GuardedType string repr | + +**Additional variables injected via `force_template_data`** (set as attribute on the function pointer, merged in `push_encode_inspected_data`): + +```python +inspection.function_pointer.force_template_data = {"key": value} +``` + +**Variables only present when `push_streaming()` is called** (generator mode — new): + +| Variable | Value | +|---|---| +| `is_streaming_generator` | `True` | +| `item_type_name` | `nice_type_name(ctx.item_type)` | + +### 2b. Type description via `TypeResolver` + GuardedTypes + +**File**: `guarded/resolver.py` + +`TypeResolver.resolve(annotation)` recursively converts any Python annotation into a `GuardedPrimitive` subclass: + +``` +int → GuardedInt +str → GuardedUtf8 +bool → GuardedBool (ProxyWrapper) +list[str] → GuardedList[GuardedUtf8] +dict[str,int]→ GuardedDict[GuardedUtf8, GuardedInt] +MyDataclass → guarded_dataclass(MyDataclass) (dynamic subclass) +MyEnum → guarded_enum(MyEnum) +Callable[..]→ GuardedCode (returns a code block string) +Iterator[T] → NOT resolved by TypeResolver — stripped to T first by CallerContext +``` + +The `__repr__` of the resulting GuardedType class becomes the `python_type_definition_dict` block in the prompt. + +### 2c. Meta-prompts (Jinja2) + +**File**: `core/meta_prompt.py` + +Two templates are rendered and combined into `messages`: + +**`EMULATE_META_PROMPT`** (system prompt): +``` +You will act as a simulator... +[optional: {% if use_json_mode %}...{% endif %}] +[optional: {% if return_none_allowed %}...{% endif %}] +[optional: {% if allow_thinking %}...{% endif %}] +[optional: {% if is_streaming_generator %}...{% endif %}] ← NEW + +def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: + """{{ function_doc }}""" + return ... + +{{ python_type_definition_dict }} +{{ function_return_as_python_type }} +[optional: {{ examples_database }}] +[optional: {{ chain_of_thought }}] +``` + +**`USER_CALL_META_PROMPT`** (user turn): +``` +{{ variables_initialization }} +{{ function_name }}({{ function_call_arguments }}) +``` + +> [!IMPORTANT] +> The following template variables exist in `EMULATE_META_PROMPT` but are **NOT populated by `encode_function()`** by default — they evaluate to Jinja2 `Undefined` (falsy) unless explicitly injected: +> `use_json_mode`, `allow_thinking`, `return_none_allowed`, `examples_database`, `chain_of_thought`, `is_streaming_generator` +> +> To activate them, either set them via `force_template_data` on the function pointer, or override `push_encode_inspected_data` in a custom Pipeline subclass. + +### 2d. Generator mode: `push_streaming()` (new) + +When `ctx.is_generator`, `emulate()` calls `pipeline.push_streaming()` instead of `pipeline.push()`. This method calls the standard `push()` chain but merges extra keys into `encoded_data` before rendering: + +```python +encoded_data |= { + "is_streaming_generator": True, + "item_type_name": nice_type_name(ctx.item_type), +} +``` + +The `EMULATE_META_PROMPT` then renders the instruction block telling the LLM to output **one `\`\`\`python` block per item**, not a JSON array or a single block: + +``` +IMPORTANT: You must output each item of the sequence SEPARATELY. +Wrap each individual item in its own fenced Python code block, one per item: + +\`\`\`python +item_1_value +\`\`\` + +\`\`\`python +item_2_value +\`\`\` + +Each block contains exactly one value of type {{ item_type_name }}. +``` + +--- + +## 3. LLM API call + +**File**: `core/base_model.py` — `Model` + +```python +# Standard (blocking) +response_dict = model.api_call(messages, llm_args) + → model.generate(messages, **llm_args) + → model._retry_wrapper(model._generate_without_retry, ...) + +# Async +response_dict = await model.api_call_async(messages, llm_args) + → loop.run_in_executor(executor, lambda: model.generate(...)) + +# Streaming (new, OpenAICompatibleModel only) +for chunk in model.generate_stream(messages, **llm_args): + ... # chunk: str delta +``` + +**`ModelCapabilities`** (used by `push_choose_model` to route to the right model): + +```python +TEXT2TEXT IMAGE2TEXT JSON_OUTPUT LOGPROBS THINK STREAMING (new) +``` + +--- + +## 4. PULL phase — LLM response → Python object + +**File**: `pipelines/simple_pipeline.py` — `OneTurnConversationPipeline.pull()` + +``` +pull(inspection, response_dict) + ├─ pull_extract_messages → raw_response: str + │ model.get_response_content(response_dict) + │ catches reasoning field if present + ├─ pull_extract_data_section → response_string: str + │ strips tags (for reasoning models) + │ strips surrounding quotes and ``` code blocks (last block) + ├─ pull_type_data_section → response_data: Any + │ type_returned_data(response_string, inspection.analyse.type) + │ → TypeResolver.resolve(expected_type) → GuardedType + │ → GuardedType.attempt(response_string) → CastingResult + │ → .unwrap() if not an explicitly Guarded annotation + └─ pull_check_uncertainty (only inside safe() context) +``` + +### 4a. `type_returned_data` + GuardedType parsing cascade + +**File**: `guarded/resolver.py`, `guarded/primitives.py` + +``` +GuardedType.attempt(raw_string) + ├─ _parse_native isinstance check → uncertainty 0.0 (STRICT) + ├─ _parse_heuristic regex / strip / cast → uncertainty ~0.1 + ├─ _parse_semantic LLM call (not implemented) → uncertainty ~0.2 + └─ _parse_knowledge knowledge base → uncertainty ~0.3+ +→ CastingResult(success, data, guarded_data, uncertainty, abstraction_level) +``` + +For **collections** (GuardedList, GuardedDict, GuardedTuple): `_parse_heuristic` calls `json.loads()` or `ast.literal_eval()` on the string, then recursively applies the inner GuardedType to each element. + +### 4b. `pull_extract_data_section` — current limitations + +This step currently: +1. Strips `` reasoning blocks (for DeepSeek/QwQ-style models). +2. Strips the **last** fenced code block (```` ``` ````) if the response ends with one. +3. Strips surrounding `"` or `'`. + +> [!WARNING] +> **Known gap**: The current `EMULATE_META_PROMPT` always asks the LLM to return a value "as if writing in REPL" (no explicit instruction on format). Some models wrap the response in `\`\`\`python\`\`\`` spontaneously, others don't. The `pull_extract_data_section` handles the wrapped case only at the end of the string. This causes parsing issues when the LLM generates intermediate reasoning text before the code block. +> +> **Planned improvement**: Add explicit format instruction per return type in the meta-prompt. The generator mode already does this with `is_streaming_generator`. + +### 4c. Generator mode: streaming PULL (new) + +Instead of `pull()`, the streaming pipeline calls `execute_stream()`: + +``` +execute_stream(inspection, force_llm_args, item_type) + └─ push_streaming(inspection, item_type) → messages (with is_streaming_generator) + └─ model.generate_stream(messages, **llm_args) → Iterator[str chunk] + └─ accumulate chunks in buffer + └─ _extract_next_code_block(buffer) + │ finds next complete ```python...``` block + └─ _pull_single_item(inspection, raw_str, item_type) + temporarily sets inspection.analyse.type = item_type + calls pull_type_data_section(inspection, raw_str) + restores original type + └─ yield typed_item +``` + +Each `\`\`\`python\`\`\`` block is processed through the full GuardedType pipeline, so `str`, `int`, dataclasses, Pydantic models all work without a dedicated parser. + +--- + +## 5. Retry logic + +**File**: `pipelines/simple_pipeline.py` — `execute()` / `execute_async()` + +```python +for attempt in range(config.MAX_RETRIES): # default: 3 + try: + messages = push(inspection) + response_dict = api_call(messages, llm_args) + response_data = pull(inspection, response_dict) + return response_data + except (ValueError, TypeError, UncertaintyError): + continue # LLM stochastic nature → retry +raise last_exception +``` + +The streaming `execute_stream()` does **not** retry — a partial stream cannot be re-tried mid-flight. Errors during item parsing are logged and skipped. + +--- + +## 6. Configuration + +**File**: `defaults.py` + +```python +config.DefaultModel # OpenAICompatibleModel(gpt-4o, api.openai.com/v1) +config.DefaultPipeline # OneTurnConversationPipeline(model_list=[DefaultModel]) +config.MAX_RETRIES # 3 +``` + +Multiple models can be loaded from `.models.yaml`. The pipeline picks the first model satisfying all required `ModelCapabilities`. + +User-level customisation points: +```python +config.DefaultPipeline.emulate_meta_prompt = MetaPrompt("...") # replace system prompt +config.DefaultPipeline.user_call_meta_prompt = MetaPrompt("...") # replace user turn +config.DefaultModel.api_parameters["temperature"] = 0.2 # LLM params +my_func.force_template_data = {"chain_of_thought": "Step 1..."} # per-function prompt data +``` + +--- + +## 7. Data-flow diagram + +``` +User function definition + │ + ▼ + emulate() called + │ + get_caller_frame() ──────────────► frame + │ │ + identify_function_of_frame() ────────► function_pointer + │ + hosta_analyze() ──────────────────► AnalyzedFunction + (+ detect_caller_context()) { name, args, type, doc } + │ { is_async, is_generator, item_type } + ▼ + ┌─────────────────────────────────────────────┐ + │ PUSH PHASE │ + │ push_detect_missing_types │ + │ push_choose_model ──────── ModelCapabilities│ + │ push_check_uncertainty (safe context) │ + │ push_encode_inspected_data │ + │ encode_function() ──► template vars dict │ + │ TypeResolver.resolve(type) │ + │ → GuardedType.__repr__() │ + │ push_select_meta_prompts │ + │ push_build_messages │ + │ EMULATE_META_PROMPT.render(vars) │ + │ USER_CALL_META_PROMPT.render(vars) │ + └───────────────────────┬─────────────────────┘ + │ messages: list[{role, content}] + ▼ + Model.api_call() / api_call_async() + Model.generate_stream() (generator mode) + │ + │ response_dict / chunk stream + ▼ + ┌─────────────────────────────────────────────┐ + │ PULL PHASE │ + │ pull_extract_messages → raw_response str │ + │ pull_extract_data_section → clean str │ + │ strip │ + │ strip last ```...``` block │ + │ pull_type_data_section │ + │ TypeResolver.resolve(return_type) │ + │ GuardedType.attempt(clean_str) │ + │ _parse_native → _parse_heuristic │ + │ (json.loads / ast.literal_eval) │ + │ .unwrap() → native Python value │ + │ pull_check_uncertainty (safe context) │ + └───────────────────────┬─────────────────────┘ + │ + ▼ + return value / yield item +``` + +--- + +## 8. Files quick reference + +| File | Role | +|---|---| +| `exec/emulate.py` | Entry point, dispatches to value or generator mode | +| `exec/ask.py` | Simpler entry point without introspection | +| `core/inspection.py` | Frame walking, function pointer identification, `Inspection` object | +| `core/analizer.py` | `hosta_analyze()`, `encode_function()`, `nice_type_name()`, `describe_type_as_python()` | +| `core/meta_prompt.py` | `MetaPrompt` (Jinja2 wrapper), `EMULATE_META_PROMPT`, `USER_CALL_META_PROMPT` | +| `core/base_model.py` | `Model` ABC, `ModelCapabilities`, `api_call`, `generate_stream` | +| `core/caller_context.py` | `CallerContext`, `detect_caller_context()` [NEW] | +| `pipelines/simple_pipeline.py` | `OneTurnConversationPipeline`: push/pull/execute/execute_stream | +| `guarded/resolver.py` | `TypeResolver.resolve()`, `type_returned_data()` | +| `guarded/primitives.py` | `GuardedPrimitive.attempt()`, `CastingResult`, `ProxyWrapper` | +| `guarded/subclassable*.py` | Concrete Guarded types (scalars, collections, callables, etc.) | +| `defaults.py` | `Config`, default model/pipeline setup, `.env` loading | +| `models/OpenAICompatible.py` | `_generate_without_retry`, `generate_stream` [NEW], `get_response_content` | diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index 6020cc27..917fe5d4 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -11,7 +11,7 @@ from .core.cost_tracker import track_costs from .core.audit import register_audit_callback, unregister_audit_callback -from .exec.ask import ask, ask_async +from .exec.ask import ask, ask_async, ask_stream, ask_stream_async from .exec.emulate import emulate, emulate_async from .exec.emulate_iterator import emulate_iterator from .exec.closure import closure, closure_async @@ -29,6 +29,8 @@ all = ( "ask", "ask_async", + "ask_stream", + "ask_stream_async", "emulate", "emulate_async", "emulate_iterator", diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index ba916a72..ff7617be 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -22,6 +22,9 @@ class AnalyzedFunction: args: List[AnalyzedArgument] type: Any doc: Optional[str] + is_async: bool = False + is_generator: bool = False + item_type: Any = str def _extract_enums_from_guarded(guarded_type, seen_enums=None) -> str: if seen_enums is None: @@ -147,7 +150,10 @@ def hosta_analyze_update(frame, analyse: AnalyzedFunction) -> AnalyzedFunction: name=analyse.name, args=new_args, type=analyse.type, - doc=analyse.doc + doc=analyse.doc, + is_async=analyse.is_async, + is_generator=analyse.is_generator, + item_type=analyse.item_type ) def _resolve_annotation(annotation, globalns=None, localns=None): @@ -171,6 +177,37 @@ def _resolve_annotation(annotation, globalns=None, localns=None): return typing.Any + +def _unwrap_iterator_type(annotation: Any) -> Any: + from typing import get_origin, get_args + import collections.abc + if annotation is None or annotation is inspect.Parameter.empty: + return str + + origin = get_origin(annotation) + args = get_args(annotation) + + _iter_origins = ( + collections.abc.Iterator, + collections.abc.Iterable, + collections.abc.AsyncIterator, + collections.abc.AsyncIterable, + collections.abc.Generator, + collections.abc.AsyncGenerator, + ) + + if origin in _iter_origins: + if args: + return args[0] + return str + + if hasattr(annotation, "__name__") and annotation.__name__ in ("Iterator", "Iterable", "AsyncIterator", "AsyncIterable", "Generator", "AsyncGenerator"): + if args: + return args[0] + return str + + return str + def hosta_analyze(frame=None, function_pointer=None) -> AnalyzedFunction: try: if frame is not None: @@ -221,11 +258,29 @@ def hosta_analyze(frame=None, function_pointer=None) -> AnalyzedFunction: result_return_type = _resolve_annotation(result_return_type, resolve_globalns, resolve_localns) result_docstring = function_pointer.__doc__ + _CO_GENERATOR = inspect.CO_GENERATOR # 0x20 + _CO_COROUTINE = inspect.CO_COROUTINE # 0x100 + _CO_ASYNC_GENERATOR = inspect.CO_ASYNC_GENERATOR # 0x200 + + code_obj = getattr(function_pointer, "__code__", None) + if code_obj: + flags = code_obj.co_flags + is_async = bool(flags & _CO_COROUTINE) or bool(flags & _CO_ASYNC_GENERATOR) + is_generator = bool(flags & _CO_GENERATOR) or bool(flags & _CO_ASYNC_GENERATOR) + else: + is_async = inspect.iscoroutinefunction(function_pointer) + is_generator = inspect.isgeneratorfunction(function_pointer) or inspect.isasyncgenfunction(function_pointer) + + item_type = _unwrap_iterator_type(result_return_type) if is_generator else result_return_type + return AnalyzedFunction( name=result_function_name, args=result_args_value_table, type=result_return_type, - doc=result_docstring + doc=result_docstring, + is_async=is_async, + is_generator=is_generator, + item_type=item_type ) diff --git a/src/OpenHosta/core/base_model.py b/src/OpenHosta/core/base_model.py index 16288e2b..eaeb7fb1 100644 --- a/src/OpenHosta/core/base_model.py +++ b/src/OpenHosta/core/base_model.py @@ -20,6 +20,7 @@ class ModelCapabilities(Enum): LOGPROBS = "LOGPROBS" # API returns token probabilities THINK = "THINK" # Model supports/emits reasoning tokens JSON_OUTPUT = "JSON_OUTPUT" # API supports native JSON mode + STREAMING = "STREAMING" # API supports token-by-token streaming class Model: def __init__(self, @@ -82,6 +83,76 @@ async def generate_async( lambda: self.generate(messages, **kwargs) ) + def generate_stream( + self, + messages: List[Dict[str, Any]], + **kwargs + ): + """High-level token streaming with retry logic.""" + return self._retry_wrapper_stream(self._generate_stream_without_retry, messages, **kwargs) + + def _retry_wrapper_stream(self, func, *args, **kwargs): + """Internal helper for rate-limit retries for generators.""" + import time + now = time.time() + time_to_wait = self.delay_next_api_call_until - now + if time_to_wait > 0: + time.sleep(time_to_wait) + + gen = func(*args, **kwargs) + try: + first_item = next(gen) + except StopIteration: + return + except RateLimitError as e: + if self.retry_delay == 0: + raise e + + now = time.time() + time_to_wait = self.delay_next_api_call_until - now + if time_to_wait <= 0: + self.delay_next_api_call_until = now + self.retry_delay + + time_to_wait = self.delay_next_api_call_until - now + if time_to_wait > 0: + time.sleep(time_to_wait) + + yield from func(*args, **kwargs) + return + + yield first_item + yield from gen + + + async def generate_stream_async( + self, + messages: List[Dict[str, Any]], + **kwargs + ): + """Async token streaming. Async-yields str delta chunks. + + Default implementation wraps generate_stream() in a thread executor + so that synchronous streaming models get async support for free. + """ + import asyncio as _asyncio + loop = _asyncio.get_running_loop() + queue: asyncio.Queue = _asyncio.Queue() + sentinel = object() + + def _producer(): + try: + for chunk in self.generate_stream(messages, **kwargs): + loop.call_soon_threadsafe(queue.put_nowait, chunk) + finally: + loop.call_soon_threadsafe(queue.put_nowait, sentinel) + + loop.run_in_executor(self.get_executor(), _producer) + while True: + item = await queue.get() + if item is sentinel: + break + yield item + def generate( self, messages: List[Dict[str, Any]], @@ -133,6 +204,12 @@ def _retry_wrapper(self, func, *args, **kwargs): def _generate_without_retry(self, messages: List[Dict[str, Any]], **kwargs) -> Dict: pass + def _generate_stream_without_retry(self, messages: List[Dict[str, Any]], **kwargs): + raise NotImplementedError( + f"{self.__class__.__name__} does not support streaming. " + "Add ModelCapabilities.STREAMING and implement _generate_stream_without_retry()." + ) + @abc.abstractmethod def _image_without_retry(self, prompt: str, **kwargs) -> Dict: pass diff --git a/src/OpenHosta/core/meta_prompt.py b/src/OpenHosta/core/meta_prompt.py index 2eb157a4..3adfd612 100644 --- a/src/OpenHosta/core/meta_prompt.py +++ b/src/OpenHosta/core/meta_prompt.py @@ -102,12 +102,11 @@ def __repr__(self): Instead, imagine a realistic or reasonable output that matches the function description. I'll ask questions by directly writing out function calls as one would call them in Python. - Respond with an appropriate return value{% if use_json_mode %} formatted as valid JSON{% endif %}, without adding any extra comments or explanations. {% if return_none_allowed %}If the provided information isn't enough to determine a clear answer, respond simply with "None".{% endif %} - If assumptions need to be made, ensure they stay realistic, align with the provided description. + If assumptions need to be made, ensure they stay realistic and align with the provided description. {% if allow_thinking %}If unable to determine a clear answer or if assumptions need to be made, - explain is in between tags.{% endif %} + explain it in between tags.{% endif %} Here's the function definition: @@ -125,7 +124,34 @@ def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: return ...appropriate return value... ``` - + + {% if is_streaming_generator %} + IMPORTANT OUTPUT FORMAT — STREAMING MODE: + You must output each item of the sequence SEPARATELY. + Wrap each individual item in its own fenced Python code block, one per item. + Do NOT produce a list literal or wrap all items in a single block. + Each block contains exactly one value of type `{{ item_type_name }}`. + + Example for 3 items of type str: + ```python + "first answer" + ``` + ```python + "second answer" + ``` + ```python + "third answer" + ``` + {% else %} + OUTPUT FORMAT: + Respond with only the return value, placed inside a single fenced Python code block. + Do not add any prose, explanation, or comments outside the block. + + Example for return type `str`: + ```python + "your answer here" + ``` + {% endif %} {% if use_json_mode %} As you return the result in JSON format, here's the schema of the JSON object you should return: {{ function_return_as_json_schema }} {% endif %} @@ -133,7 +159,7 @@ def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: {% if examples_database %}Here are some examples of expected input and output: {{ examples_database }}{% endif %} - {% if chain_of_thought %}To solve the request, you have to follow theses intermediate steps. Give only the final result, don't give the result of theses intermediate steps: + {% if chain_of_thought %}To solve the request, you have to follow these intermediate steps. Give only the final result, don't give the result of these intermediate steps: {{ chain_of_thought }}{% endif %} {% if allow_thinking %} diff --git a/src/OpenHosta/exec/ask.py b/src/OpenHosta/exec/ask.py index e3fc2ca5..36070b58 100644 --- a/src/OpenHosta/exec/ask.py +++ b/src/OpenHosta/exec/ask.py @@ -33,44 +33,7 @@ def ask( dummy_inspection = Inspection(None, None, dummy_analyse) model = config.DefaultPipeline.push_choose_model(dummy_inspection) - message = [] - if system is not None: - message.append({"role": "system", "content": [{"type": "text", "text": system}]}) - - message.append( - {"role": "user", "content": [ - { "type": "text", "text": user_message } - ]}) - - for arg in unnamed_other_args: - named_other_args["arg"+str(unnamed_other_args.index(arg))] = arg - - for key, arg in named_other_args.items(): - try: - import PIL.Image - import base64 - import io - pil_image_supported = True - except ImportError: - pil_image_supported = False - - if pil_image_supported and isinstance(arg, PIL.Image.Image): - buffered= io.BytesIO() - arg.save(buffered, format="PNG") - img_string = base64.b64encode(buffered.getvalue()).decode("utf-8") - message[-1]["content"].append( - {"type": "image_url", "image_url": { - "url": "data:image/png;base64," + img_string - } - }) - else: - - # Add a system message if missing - if len(message) == 1: - system_message = {"role": "system", "content": [{"type": "text", "text": ""}]} - message = [system_message] + message - - message[0]["content"][0]["text"] += f"\n{key}:\n{str(arg)}\n" + message = _build_ask_message(user_message, unnamed_other_args, named_other_args, system) force_llm_args["force_json_output"] = force_json_output @@ -111,56 +74,176 @@ async def ask_async( dummy_inspection = Inspection(None, None, dummy_analyse) model = config.DefaultPipeline.push_choose_model(dummy_inspection) + message = _build_ask_message(user_message, unnamed_other_args, named_other_args, system) + + force_llm_args["force_json_output"] = force_json_output + + response_dict = await model.api_call_async( + messages=message, + llm_args=force_llm_args + ) + + response = model.get_response_content(response_dict) + + # No type detection + answer = response + + return answer + + +def _build_ask_message( + user_message: str, + unnamed_other_args, + named_other_args: dict, + system: Optional[str], +) -> list: + """Shared message-building logic for ask_stream / ask_stream_async.""" message = [] if system is not None: message.append({"role": "system", "content": [{"type": "text", "text": system}]}) - - message.append( - {"role": "user", "content": [ - { "type": "text", "text": user_message } - ]}) - for arg in unnamed_other_args: - named_other_args["arg"+str(unnamed_other_args.index(arg))] = arg + message.append({"role": "user", "content": [{"type": "text", "text": user_message}]}) + + named_other_args = dict(named_other_args) + for i, arg in enumerate(unnamed_other_args): + named_other_args[f"arg{i}"] = arg for key, arg in named_other_args.items(): try: - import PIL.Image - import base64 - import io - pil_image_supported = True + import PIL.Image, base64, io + pil_ok = True except ImportError: - pil_image_supported = False - - if pil_image_supported and isinstance(arg, PIL.Image.Image): - buffered= io.BytesIO() + pil_ok = False + + if pil_ok and isinstance(arg, PIL.Image.Image): + buffered = io.BytesIO() arg.save(buffered, format="PNG") - img_string = base64.b64encode(buffered.getvalue()).decode("utf-8") + img_str = base64.b64encode(buffered.getvalue()).decode("utf-8") message[-1]["content"].append( - {"type": "image_url", "image_url": { - "url": "data:image/png;base64," + img_string - } - }) + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + img_str}} + ) else: - - # Add a system message if missing if len(message) == 1: - system_message = {"role": "system", "content": [{"type": "text", "text": ""}]} - message = [system_message] + message - + message = [{"role": "system", "content": [{"type": "text", "text": ""}]}] + message message[0]["content"][0]["text"] += f"\n{key}:\n{str(arg)}\n" - force_llm_args["force_json_output"] = force_json_output + return message - response_dict = await model.api_call_async( - messages=message, - llm_args=force_llm_args - ) - response = model.get_response_content(response_dict) - - # No type detection - answer = response +def ask_stream( + user_message: str, + *unnamed_other_args, + system: Optional[str] = "You are a helpful assistant.", + model: Optional[Model] = None, + interval_ms: float = 50, + force_llm_args: Optional[dict] = {}, + **named_other_args, +): + """ + Yields text chunks from the LLM as they stream in, grouped by time window. + + Tokens are accumulated for up to `interval_ms` milliseconds, then yielded + together as a single str chunk. On end-of-stream, any remaining tokens are + flushed immediately without waiting for the next window. + + Parameters + ---------- + user_message : str + The question or prompt to send to the model. + interval_ms : float + Maximum time (in milliseconds) to accumulate tokens before yielding. + Default: 50 ms. Set to 0 to yield every token individually. + model : Model, optional + Override the default model. Must support ModelCapabilities.STREAMING. + force_llm_args : dict, optional + Extra arguments forwarded to the LLM API. + """ + import time as _time + from ..core.base_model import ModelCapabilities - return answer - \ No newline at end of file + if model is None: + from ..core.inspection import Inspection + from ..core.analizer import AnalyzedFunction, AnalyzedArgument + dummy_analyse = AnalyzedFunction(name="ask", args=[], type=str, doc=user_message) + for i, arg in enumerate(unnamed_other_args): + dummy_analyse.args.append(AnalyzedArgument(name=f"arg{i}", value=arg, type=None)) + for key, arg in named_other_args.items(): + dummy_analyse.args.append(AnalyzedArgument(name=key, value=arg, type=None)) + dummy_inspection = Inspection(None, None, dummy_analyse) + model = config.DefaultPipeline.push_choose_model(dummy_inspection) + + message = _build_ask_message(user_message, unnamed_other_args, named_other_args, system) + llm_args = dict(force_llm_args) + + interval_s = interval_ms / 1000.0 + buffer = "" + window_start = _time.monotonic() + + for chunk in model.generate_stream(message, **llm_args): + if interval_s > 0 and (_time.monotonic() - window_start) >= interval_s: + if buffer: + yield buffer + buffer = "" + window_start = _time.monotonic() + buffer += chunk + if interval_s <= 0: + yield buffer + buffer = "" + window_start = _time.monotonic() + + # Flush any remaining tokens immediately on end-of-stream + if buffer: + yield buffer + + +async def ask_stream_async( + user_message: str, + *unnamed_other_args, + system: Optional[str] = "You are a helpful assistant.", + model: Optional[Model] = None, + interval_ms: float = 50, + force_llm_args: Optional[dict] = {}, + **named_other_args, +): + """ + Async version of ask_stream. Use with ``async for chunk in ask_stream_async(...)``. + + Tokens are accumulated for up to `interval_ms` ms, then yielded as a single chunk. + The last chunk is always flushed immediately on end-of-stream. + """ + import asyncio as _asyncio + import time as _time + from ..core.base_model import ModelCapabilities + + if model is None: + from ..core.inspection import Inspection + from ..core.analizer import AnalyzedFunction, AnalyzedArgument + dummy_analyse = AnalyzedFunction(name="ask", args=[], type=str, doc=user_message) + for i, arg in enumerate(unnamed_other_args): + dummy_analyse.args.append(AnalyzedArgument(name=f"arg{i}", value=arg, type=None)) + for key, arg in named_other_args.items(): + dummy_analyse.args.append(AnalyzedArgument(name=key, value=arg, type=None)) + dummy_inspection = Inspection(None, None, dummy_analyse) + model = config.DefaultPipeline.push_choose_model(dummy_inspection) + + message = _build_ask_message(user_message, unnamed_other_args, named_other_args, system) + llm_args = dict(force_llm_args) + + interval_s = interval_ms / 1000.0 + buffer = "" + window_start = _time.monotonic() + + async for chunk in model.generate_stream_async(message, **llm_args): + if interval_s > 0 and (_time.monotonic() - window_start) >= interval_s: + if buffer: + yield buffer + buffer = "" + window_start = _time.monotonic() + buffer += chunk + if interval_s <= 0: + yield buffer + buffer = "" + window_start = _time.monotonic() + + if buffer: + yield buffer \ No newline at end of file diff --git a/src/OpenHosta/exec/emulate.py b/src/OpenHosta/exec/emulate.py index 215daa34..1d6d6abb 100644 --- a/src/OpenHosta/exec/emulate.py +++ b/src/OpenHosta/exec/emulate.py @@ -8,6 +8,7 @@ from ..pipelines import OneTurnConversationPipeline + def emulate( *, pipeline: Optional[OneTurnConversationPipeline] = config.DefaultPipeline, @@ -16,26 +17,38 @@ def emulate( """ Emulates a function's behavior using a language model. - This function uses a language model to emulate the behavior of a Python function - based on its signature, docstring, and context. - + Automatically adapts to the calling context: + + - ``def f() -> T: return emulate()`` + Synchronous value — calls the LLM and returns the fully resolved value. + + - ``def f() -> Iterator[T]: yield emulate()`` + Synchronous generator — streams items from the LLM, yielding each typed + item as its ```python``` block completes. + Args: - - pipeline (Optional[OneTurnConversationPipeline]): The pipeline used for emulation. If None, uses the default one. - - force_llm_args: Additional keyword arguments to pass to the language model. - + pipeline: The pipeline used for emulation. If None, uses the default one. + force_llm_args: Additional keyword arguments to pass to the language model. + Returns: - - Any: The emulated function's return value, processed by the model and optionally modified by post_callback. + Any: The emulated return value, or a generator of items in generator mode. """ - # You can retrive this frame using get_last_frame(your_emulated_function) in interactive mode + # You can retrieve this frame using get_last_frame(your_emulated_function) frame = get_caller_frame() # Get everything about the function you are emulating inspection = get_hosta_inspection(frame) - - # Delegate the entire execution (including retries) to the pipeline - response_data = pipeline.execute(inspection, force_llm_args) - - return response_data + + # Detect whether the caller is a generator function + is_generator = inspection.analyse.is_generator + item_type = inspection.analyse.item_type + + if is_generator: + # Return a sync generator; the caller yields it with `yield from emulate()` + return pipeline.execute_stream(inspection, force_llm_args, item_type) + else: + # Existing behaviour — synchronous single value + return pipeline.execute(inspection, force_llm_args) async def emulate_async( @@ -44,25 +57,32 @@ async def emulate_async( force_llm_args: Optional[dict] = {}, ) -> Any: """ - Emulates a function's behavior using a language model. + Emulates a function's behavior using a language model (async version). + + Automatically adapts to the calling context: + + - ``async def f() -> T: return await emulate_async()`` + Asynchronous value — awaits the LLM response and returns the resolved value. + + - ``async def f() -> AsyncIterator[T]: yield emulate_async()`` + Async generator — streams items via the async LLM API. - This function uses a language model to emulate the behavior of a Python function - based on its signature, docstring, and context. - Args: - - pipeline (Optional[OneTurnConversationPipeline]): The pipeline used for emulation. If None, uses the default one. - - force_llm_args: Additional keyword arguments to pass to the language model. - + pipeline: The pipeline used for emulation. If None, uses the default one. + force_llm_args: Additional keyword arguments to pass to the language model. + Returns: - - Any: The emulated function's return value, processed by the model and optionally modified by post_callback. + Any: The emulated return value, or an async generator of items. """ - # You can retrive this frame using get_last_frame(your_emulated_function) in interactive mode frame = get_caller_frame() - - # Get everything about the function you are emulating inspection = get_hosta_inspection(frame) - - # Delegate the entire execution (including retries) to the pipeline - response_data = await pipeline.execute_async(inspection, force_llm_args) - - return response_data + + is_generator = inspection.analyse.is_generator + item_type = inspection.analyse.item_type + + if is_generator: + # Return an async generator; the caller does `async for x in await emulate_async(): yield x` + return pipeline.execute_stream_async(inspection, force_llm_args, item_type) + else: + # Existing behaviour — async single value + return await pipeline.execute_async(inspection, force_llm_args) diff --git a/src/OpenHosta/models/OpenAICompatible.py b/src/OpenHosta/models/OpenAICompatible.py index 56a957c9..325443e5 100644 --- a/src/OpenHosta/models/OpenAICompatible.py +++ b/src/OpenHosta/models/OpenAICompatible.py @@ -14,7 +14,10 @@ def __init__(self, max_async_calls:int = 7, additionnal_headers: Dict[str, Any] = {}, api_parameters:Dict[str, Any] = {}, - capabilities:Set[ModelCapabilities] = {ModelCapabilities.TEXT2TEXT}, + capabilities:Set[ModelCapabilities] = { + ModelCapabilities.TEXT2TEXT, + ModelCapabilities.STREAMING, + }, base_url: str = "https://api.openai.com/v1", chat_completion_url: str = "/chat/completions", embedding_url: str = "/embeddings", @@ -166,6 +169,79 @@ def _generate_without_retry(self, messages: List[Dict[str, Any]], **kwargs) -> D else: raise RequestError(f"[OpenAICompatibleModel._generate_without_retry] Request failed ({response.status_code}):\n{response.text}") + def _generate_stream_without_retry(self, messages: List[Dict[str, Any]], **kwargs): + """Yield raw text delta chunks from the OpenAI-compatible SSE stream. + + Sends stream=True to the API and parses Server-Sent Events line by line. + Each yielded value is a non-empty str delta (may be multi-token). + """ + import json as _json + + llm_args = dict(kwargs) + if "force_json_output" in llm_args and ModelCapabilities.JSON_OUTPUT not in self.capabilities: + llm_args.pop("force_json_output") + + api_key = self._get_api_key() + if api_key is None and "api.openai.com/v1" in self.base_url: + api_key = os.environ.get("OPENAI_API_KEY", None) + if api_key is None: + raise ApiKeyError("[OpenAICompatibleModel._generate_stream_without_retry] Empty API key.") + + l_body: dict = { + "model": self.model_name, + "messages": messages, + "stream": True, + } + headers = self._get_headers(api_key) + + all_api_parameters = self.api_parameters | llm_args + for key, value in all_api_parameters.items(): + if key == "force_json_output" and value: + l_body["response_format"] = {"type": "json_object"} + elif key == "stream": + pass # already set + else: + l_body[key] = value + + full_url = f"{self.base_url}{self.chat_completion_url}" + + response = requests.post( + full_url, headers=headers, json=l_body, + timeout=self.timeout, stream=True + ) + self._handle_rate_limit_headers(response) + + if response.status_code == 429: + raise RateLimitError(f"[OpenAICompatibleModel._generate_stream_without_retry] Rate limit. {response.text}") + if response.status_code == 401: + raise ApiKeyError(f"[OpenAICompatibleModel._generate_stream_without_retry] Unauthorized. {response.text}") + if response.status_code != 200: + raise RequestError(f"[OpenAICompatibleModel._generate_stream_without_retry] Failed ({response.status_code}):\n{response.text}") + + self._nb_requests += 1 + for raw_line in response.iter_lines(): + if not raw_line: + continue + # SSE lines look like: "data: {...}" or "data: [DONE]" + if isinstance(raw_line, bytes): + raw_line = raw_line.decode("utf-8") + if not raw_line.startswith("data:"): + continue + data_str = raw_line[len("data:"):].strip() + if data_str == "[DONE]": + break + try: + chunk = _json.loads(data_str) + except _json.JSONDecodeError: + continue + delta = ( + chunk.get("choices", [{}])[0] + .get("delta", {}) + .get("content") or "" + ) + if delta: + yield delta + def _image_without_retry(self, prompt: str, **kwargs) -> Dict: api_key = self._get_api_key() headers = self._get_headers(api_key) diff --git a/src/OpenHosta/pipelines/simple_pipeline.py b/src/OpenHosta/pipelines/simple_pipeline.py index a70dd439..29d5e634 100644 --- a/src/OpenHosta/pipelines/simple_pipeline.py +++ b/src/OpenHosta/pipelines/simple_pipeline.py @@ -7,7 +7,7 @@ from ..core.errors import UncertaintyError, UnreproducibleError -from ..core.analizer import encode_function +from ..core.analizer import encode_function, nice_type_name from ..core.base_model import Model, ModelCapabilities from ..core.inspection import Inspection from ..core.meta_prompt import MetaPrompt, EMULATE_META_PROMPT, USER_CALL_META_PROMPT @@ -343,6 +343,32 @@ def pull_extract_messages(self, inspection:Inspection, response_dict:dict) -> di return raw_response + @staticmethod + def _extract_next_code_block(buffer: str): + """Find the next complete ```python...``` block in *buffer*. + + Returns + ------- + (content: str, remainder: str) + *content* is the text between the delimiters (stripped). + *remainder* is everything after the closing ``` . + If no complete block is found, returns (None, buffer). + """ + START = "```python" + END = "```" + start = buffer.find(START) + if start == -1: + return None, buffer + content_start = start + len(START) + # Search for closing ``` *after* the opening tag + end = buffer.find(END, content_start) + if end == -1: + return None, buffer # block not yet closed + content = buffer[content_start:end] + remainder = buffer[end + len(END):] + return content, remainder + + def pull_extract_data_section(self, inspection:Inspection, raw_response:str) -> Any: """Data & Schema Level""" @@ -420,7 +446,30 @@ def push(self, inspection:Inspection) -> dict: messages = self.push_build_messages(inspection, meta_messages, encoded_data) return messages - + + def push_streaming(self, inspection: Inspection, item_type) -> dict: + """Like push(), but injects is_streaming_generator and item_type_name into + the encoded_data before rendering the meta-prompt templates. + + These extra keys activate the streaming-mode instruction block inside + EMULATE_META_PROMPT ({% if is_streaming_generator %}). + """ + inspection.pipeline = self + + inspection = self.push_detect_missing_types(inspection) + _ = self.push_choose_model(inspection) + inspection = self.push_check_uncertainty(inspection) + + meta_messages = self.push_select_meta_prompts(inspection) + encoded_data = self.push_encode_inspected_data(inspection) + + # Inject generator-mode template variables + encoded_data["is_streaming_generator"] = True + encoded_data["item_type_name"] = nice_type_name(item_type) + + messages = self.push_build_messages(inspection, meta_messages, encoded_data) + return messages + def pull(self, inspection:Inspection, response_dict ): inspection.logs["rational"] = "" inspection.logs["answer"] = "" @@ -548,5 +597,76 @@ async def execute_async(self, inspection: Inspection, force_llm_args: dict) -> A "attempts": max_retries }) raise last_exception - + + def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type): + """Stream typed items from the LLM, one ```python block per item.""" + messages = self.push_streaming(inspection, item_type) + llm_args = inspection.force_llm_args | force_llm_args + + buffer = "" + for chunk in inspection.model.generate_stream(messages, **llm_args): + buffer += chunk + # Extract as many complete blocks as possible from the buffer + while True: + content, remainder = self._extract_next_code_block(buffer) + if content is None: + break + buffer = remainder + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + + # Flush any remaining partial block at end-of-stream + if buffer.strip(): + # Force-close an open block if needed + probe = buffer if buffer.rstrip().endswith("```") else buffer + "\n```" + content, _ = self._extract_next_code_block(probe) + if content is not None: + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + + + async def execute_stream_async(self, inspection: Inspection, force_llm_args: dict, item_type): + """Async version of _execute_stream.""" + messages = self.push_streaming(inspection, item_type) + llm_args = inspection.force_llm_args | force_llm_args + + buffer = "" + async for chunk in inspection.model.generate_stream_async(messages, **llm_args): + buffer += chunk + while True: + content, remainder = self._extract_next_code_block(buffer) + if content is None: + break + buffer = remainder + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + + if buffer.strip(): + probe = buffer if buffer.rstrip().endswith("```") else buffer + "\n```" + content, _ = self._extract_next_code_block(probe) + if content is not None: + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + + + def _pull_single_item(self, inspection: Inspection, raw_str: str, item_type) -> Any: + """Run the standard pull_type_data_section pipeline on a single extracted item. + + Temporarily overrides inspection.analyse.type with *item_type* so that the + full GuardedType resolution chain applies to the individual item rather than + the outer Iterator[item_type] annotation. + """ + original_type = inspection.analyse.type + inspection.analyse.type = item_type + try: + return self.pull_type_data_section(inspection, raw_str.strip()) + except (ValueError, TypeError, SyntaxError) as e: + print(f"[execute_stream] Skipping unparseable item: {e!r} — raw: {raw_str!r}") + return None + finally: + inspection.analyse.type = original_type diff --git a/tests/exec/test_ask_stream.py b/tests/exec/test_ask_stream.py new file mode 100644 index 00000000..d0edced1 --- /dev/null +++ b/tests/exec/test_ask_stream.py @@ -0,0 +1,62 @@ +import pytest +import asyncio +from typing import Iterator, AsyncIterator +import OpenHosta +from OpenHosta.core.base_model import ModelCapabilities +from OpenHosta.models.OpenAICompatible import OpenAICompatibleModel + +class MockStreamingModel(OpenAICompatibleModel): + def __init__(self, chunks, **kwargs): + super().__init__(model_name="mock-streamer") + self.model_name = "mock-streamer" + self.capabilities = {ModelCapabilities.STREAMING, ModelCapabilities.TEXT2TEXT} + self.chunks = chunks + + def _generate_stream_without_retry(self, messages, **kwargs): + for chunk in self.chunks: + yield chunk + + async def generate_stream_async(self, messages, **kwargs): + for chunk in self.chunks: + await asyncio.sleep(0.01) + yield chunk + + def generate(self, messages, **kwargs): + return {"choices": [{"message": {"content": "".join(self.chunks)}}]} + + +def test_ask_stream_sync(): + model = MockStreamingModel(["Hello", " ", "World", "!"]) + # interval_ms=0 means yield every chunk as it arrives + chunks = list(OpenHosta.ask_stream("Say hello", model=model, interval_ms=0)) + assert chunks == ["Hello", " ", "World", "!"] + + +@pytest.mark.asyncio +async def test_ask_stream_async(): + model = MockStreamingModel(["Hello", " ", "Async", "!"]) + chunks = [] + async for chunk in OpenHosta.ask_stream_async("Say hello", model=model, interval_ms=0): + chunks.append(chunk) + assert chunks == ["Hello", " ", "Async", "!"] + +def test_ask_stream_interval(): + import time + + class SlowModel(OpenAICompatibleModel): + def __init__(self): + super().__init__(model_name="slow") + self.model_name = "slow" + self.capabilities = {ModelCapabilities.STREAMING, ModelCapabilities.TEXT2TEXT} + def _generate_stream_without_retry(self, messages, **kwargs): + # yield quickly + yield "A" + yield "B" + # wait a bit to trigger interval + time.sleep(0.06) + yield "C" + + # With interval=50ms, 'A' and 'B' should be grouped, then 'C' flushed at the end + model = SlowModel() + chunks = list(OpenHosta.ask_stream("Slow", model=model, interval_ms=50)) + assert chunks == ["AB", "C"] diff --git a/tests/exec/test_emulate_generator.py b/tests/exec/test_emulate_generator.py new file mode 100644 index 00000000..30c43b08 --- /dev/null +++ b/tests/exec/test_emulate_generator.py @@ -0,0 +1,87 @@ +import pytest +import asyncio +from typing import Iterator, AsyncIterator, List +from OpenHosta import emulate, emulate_async +from OpenHosta.core.base_model import ModelCapabilities +from OpenHosta.models.OpenAICompatible import OpenAICompatibleModel + +class MockCodeBlockModel(OpenAICompatibleModel): + def __init__(self, blocks: List[str]): + super().__init__(model_name="mock-code-block") + self.model_name = "mock-code-block" + self.capabilities = {ModelCapabilities.STREAMING, ModelCapabilities.TEXT2TEXT} + self.blocks = blocks + + def _generate_stream_without_retry(self, messages, **kwargs): + for block in self.blocks: + yield "```python\n" + yield block + "\n" + yield "```\n" + + async def generate_stream_async(self, messages, **kwargs): + for block in self.blocks: + await asyncio.sleep(0.01) + yield "```python\n" + yield block + "\n" + yield "```\n" + + def generate(self, messages, **kwargs): + content = "" + for block in self.blocks: + content += f"```python\n{block}\n```\n" + return {"choices": [{"message": {"content": content}}]} + +# Create a custom pipeline to avoid actual API calls during tests +from OpenHosta.pipelines import OneTurnConversationPipeline +import copy + +def get_mock_pipeline(blocks: List[str]): + pipeline = OneTurnConversationPipeline(model_list=[MockCodeBlockModel(blocks)]) + return pipeline + +def test_emulate_sync_generator(): + pipeline = get_mock_pipeline(["1", "2", "3"]) + + def my_gen() -> Iterator[int]: + """A test docstring.""" + yield from emulate(pipeline=pipeline) + + results = list(my_gen()) + assert results == [1, 2, 3] + +def test_emulate_sync_value(): + # When not a generator, emulate returns a single value + # (the first block pulled by pull_extract_data_section) + pipeline = get_mock_pipeline(["42"]) + + def my_val() -> int: + """A test docstring.""" + return emulate(pipeline=pipeline) + + result = my_val() + assert result == 42 + +@pytest.mark.asyncio +async def test_emulate_async_generator(): + pipeline = get_mock_pipeline(["10", "20"]) + + async def my_async_gen() -> AsyncIterator[int]: + """A test docstring.""" + async for x in await emulate_async(pipeline=pipeline): + yield x + + results = [] + async for item in my_async_gen(): + results.append(item) + assert results == [10, 20] + +@pytest.mark.asyncio +async def test_emulate_async_value(): + pipeline = get_mock_pipeline(["100"]) + + async def my_async_val() -> int: + """A test docstring.""" + return await emulate_async(pipeline=pipeline) + + result = await my_async_val() + assert result == 100 diff --git a/tests/functionnal/test_func_ask_stream.py b/tests/functionnal/test_func_ask_stream.py new file mode 100644 index 00000000..3298f421 --- /dev/null +++ b/tests/functionnal/test_func_ask_stream.py @@ -0,0 +1,33 @@ +import pytest +import os +from dotenv import load_dotenv + +load_dotenv() + +from OpenHosta import ask_stream, ask_stream_async +from asyncio import run + +def test_ask_stream_basic(): + """Test that ask_stream yields chunks that form a complete answer.""" + prompt = "Spell the word 'hello' letter by letter. Do not add any punctuation or extra text." + chunks = list(ask_stream(prompt, interval_ms=10)) + + full_response = "".join(chunks).lower() + + assert len(chunks) >= 1, "Expected at least one chunk to be returned" + assert "h" in full_response and "e" in full_response and "l" in full_response and "o" in full_response, f"Expected letters of 'hello' in response, got: {full_response}" + +def test_ask_stream_async_basic(): + """Test that ask_stream_async yields chunks that form a complete answer.""" + async def app(): + prompt = "Count to 3: 1, 2, 3" + chunks = [] + async for chunk in ask_stream_async(prompt, interval_ms=10): + chunks.append(chunk) + return chunks + + chunks = run(app()) + full_response = "".join(chunks) + + assert len(chunks) >= 1, "Expected at least one chunk" + assert "1" in full_response and "3" in full_response, f"Expected numbers in response, got: {full_response}" diff --git a/tests/functionnal/test_func_emulate_generator.py b/tests/functionnal/test_func_emulate_generator.py new file mode 100644 index 00000000..313775c4 --- /dev/null +++ b/tests/functionnal/test_func_emulate_generator.py @@ -0,0 +1,48 @@ +import pytest +import os +from typing import Iterator, AsyncIterator +from dotenv import load_dotenv +from asyncio import run + +load_dotenv() + +from OpenHosta import emulate, emulate_async + +def test_emulate_sync_generator_basic(): + """Test emulate() inside a synchronous generator returns typed items.""" + + def generate_even_numbers() -> Iterator[int]: + """ + Generate exactly 3 even numbers sequentially, starting from 2. + For example: 2, 4, 6. + """ + yield from emulate() + + items = list(generate_even_numbers()) + + # We requested 3 numbers, the LLM should give us roughly that. + # At a minimum it should be an iterable of ints. + assert len(items) >= 1, "Expected at least 1 item" + for item in items: + assert isinstance(item, int), f"Expected int, got {type(item)}" + assert item % 2 == 0, f"Expected even number, got {item}" + +def test_emulate_async_generator_basic(): + """Test emulate_async() inside an asynchronous generator returns typed items.""" + + async def app(): + async def generate_vowels() -> AsyncIterator[str]: + """ + Generate the 5 basic vowels (a, e, i, o, u) one by one. + """ + async for vowel in await emulate_async(): + yield vowel + + results = [] + async for v in generate_vowels(): + results.append(v.lower()) + return results + + vowels = run(app()) + assert len(vowels) >= 3, "Expected at least 3 vowels generated" + assert "a" in vowels, "Missing 'a'" diff --git a/tests/imagen/test_gen_garden.py b/tests/imagen/test_gen_garden.py new file mode 100644 index 00000000..2aab301b --- /dev/null +++ b/tests/imagen/test_gen_garden.py @@ -0,0 +1,116 @@ + + + +garden_description= """ +## Localisation +Ville: Göteborg, Suède +Orientation: Nord +Climat: Tempéré et océanique +Sol: Tourbeux, peu de consistence, assez pauvre +Dimensions à déterminer +Humidité du climat, mais la terre retient peu l'eau + +## Composition +Le jardin est en pente du haut (Nord-Ouest) vers la maison (Sud). +Il est bordé au nord par des rochers, au nord-ouest par 5 arbres (érables et chênes), +au sud-ouest par une palissade, au sud-est par le jardin du voisin, au sud par la maison. +Les zones proches de la maison et du jardin du voisin sont donc ombragées une partie de la journée, sauf en plein été quand le soleil est vraiment haut. + +### Actuelle +deux plants de framboisiers vers les érables +""" + +import os +os.environ["GTK_PATH"] = "" + + +from OpenHosta import emulate, ask +from OpenHosta import print_last_decoding, print_last_prompt + +# ask("quel est ton nom de model ? quelle version et quelle data de fin d'entraienement ?") + +import PIL +import PIL.Image +import pyautogui +import time + +img = pyautogui.screenshot(region=(0,0,1920, 1080)) + +img.show() + +ask("what is the title of the main window", img= img) + +def get_bounding_box(element_name:str, img:PIL.Image.Image) -> tuple[int,int,int,int]: + """ + This function will return the bounding box of the element in the image. + + Args: + element_name (str): The name of the element to find in the image. + img (PIL.Image.Image): The image to search in. + + Returns: + tuple[int,int,int,int]: The bounding box of the element in the image. (x1, y1, x2, y2) + """ + return emulate() + +def what_is_the_python_module_for(action_description:str) -> str: + """ + Identify the best python module to achieve the described action. + + Return the module name as a string, ready to be used when loading with import + """ + return emulate() + + +from OpenHosta import config, MetaPrompt +config.DefaultModel.api_parameters["reasoning_effort"] = "low" +config.DefaultModel.api_parameters["max_tokens"] = 1000 + +config.DefaultPipeline.emulate_meta_prompt = MetaPrompt(''' + The user write its question to you formated as a python function call. + Format your answer in the requested python type as if you are writing the outut in REPL. + + ```python + def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: + """ + {{ function_doc | indent(4, true) }} + """ + + ... + ...behavior to be simulated... + ... + + return ...appropriate return value... + ``` +''') + +t0 = time.time() +what_is_the_python_module_for("resize an image") +t1 = time.time() +print(f"Time taken: {t1-t0}") + +print_last_prompt(what_is_the_python_module_for) + +box = get_bounding_box("left menu (scaleway console)", img) +box = get_bounding_box("botton for validaton", img) + + +config.DefaultModel.api_parameters["reasoning_effort"] = "low" +config.DefaultModel.api_parameters["reasoning_effort"] = "high" +r=ask("quel est ton nom de model ? quelle version et quelle data de fin d'entraienement ?") + +t0 = time.time() +box = get_bounding_box("View code model windows", img.reduce(4)) +t1 = time.time() +print(f"Time taken: {t1-t0}") + + + +from OpenHosta.core.inspection import Inspection +insp:Inspection = get_bounding_box.hosta_inspection + +insp.model.api_parameters +insp.logs +img.size +win=img.reduce(4).crop(box) +win.show() \ No newline at end of file From 126f4011dc4628e178b59f9ac7c136ed916b3a98 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 17:34:41 +0200 Subject: [PATCH 02/33] feat: implement streaming support for iterative emulation via generator and async generator functions --- docs/doc.md | 1 + docs/streaming.md | 150 ++++++++++++++++++ pyproject.toml | 2 +- src/OpenHosta/__init__.py | 2 +- src/OpenHosta/core/analizer.py | 27 +++- src/OpenHosta/core/uncertainty.py | 75 +++++++-- src/OpenHosta/exec/emulate.py | 12 +- .../test_func_emulate_generator.py | 2 +- tests/functionnal/test_safe.py | 4 +- tests/manual/base_functions.py | 18 +++ 10 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 docs/streaming.md diff --git a/docs/doc.md b/docs/doc.md index 54e582c0..290508ae 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -38,6 +38,7 @@ Take off your conceptual hat and observe OpenHosta in action through functional - 🗃️ [**Data Extraction:**](examples/data_extraction.md) Populating massive `Dataclasses` and `Pydantic` modules straight from unstructured text blobs. - 👁️ [**Local OCR with Ollama:**](examples/ocr_local_ollama.md) Passing images using `PIL.Image` directly into `emulate`, performing OCR securely and locally using `glm-ocr`. - ⚡ [**Parallel Processing:**](examples/parallel_processing.md) Running asynchronous workloads, parsing dataclasses like invoices, and batching prompts. +- 🌊 [**Streaming & Iteration:**](streaming.md) Receiving results token-by-token or item-by-item to improve UX and handle long responses. --- diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 00000000..64a9e30b --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,150 @@ +# Streaming & Iterative Emulation + +OpenHosta supports streaming results from Large Language Models (LLMs) both for raw text (via `ask_stream`) and for structured Python objects (via `emulate`). + +This is particularly useful for: +- **Long responses:** Avoiding timeouts and providing immediate feedback in user interfaces. +- **Processing sequences:** Handling lists of items as they are generated. +- **Parallelism:** Starting the next processing step as soon as the first item of a collection is available. + +--- + +## Streaming Raw Text with `ask_stream` + +The `ask_stream` function allows you to receive tokens from the model as they are generated. To avoid overwhelming your application with too many events, you can specify an `interval_ms` to buffer tokens. + +### Synchronous Usage +```python +from OpenHosta import ask_stream + +# Receive groups of tokens every 100ms +for chunk in ask_stream("Tell me a long story about space exploration.", interval_ms=100): + print(chunk, end="", flush=True) +``` + +### Asynchronous Usage +```python +import asyncio +from OpenHosta import ask_stream_async + +async def main(): + async for chunk in ask_stream_async("Explain quantum computing simply.", interval_ms=50): + print(chunk, end="", flush=True) + +asyncio.run(main()) +``` + +--- + +## Iterative Emulation with `emulate` + +When a function is defined as a generator (using `yield`), `emulate()` automatically switches to **Streaming Mode**. Instead of waiting for the full response, it parses individual Python objects out of the LLM stream as they are completed. + +### How it works +1. **Meta-Prompt instruction:** OpenHosta tells the LLM to output a sequence of items, each wrapped in its own ` ```python ` block. +2. **On-the-fly parsing:** The pipeline monitors the stream. As soon as a code block is closed ( ` ``` ` ), it extracts the content, casts it to the target type, and yields it immediately. + +### Example: Processing Paragraphs +If you want to process a long text paragraph by paragraph: + +```python +from typing import Iterator +from OpenHosta import emulate + +def split_into_paragraphs(text: str) -> Iterator[str]: + """ + Analyzes the input text and yields each paragraph as a separate string. + Each paragraph should be a coherent unit of thought. + """ + yield from emulate() + +# Note: You can also use 'return emulate()' if you prefer, +# as long as the return type is annotated as an Iterator. +def split_into_paragraphs_alt(text: str) -> Iterator[str]: + return emulate() + +long_text = "... some very long text ..." + +for paragraph in split_into_paragraphs(long_text): + print(f"--- Received Paragraph ---\n{paragraph}\n") +``` + +### Example: Generating Complex Objects +Streaming works with any supported type, including `Dataclasses` and `Pydantic` models. + +```python +from typing import Iterator +from dataclasses import dataclass +from OpenHosta import emulate + +@dataclass +class ActionItem: + task: str + priority: int + +def extract_action_items(meeting_notes: str) -> Iterator[ActionItem]: + """ + Extracts action items from the meeting notes. + Yields one ActionItem at a time. + """ + yield from emulate() + +notes = "We need to fix the bug by tomorrow (high priority). Also, remember to buy milk." + +for item in extract_action_items(notes): + print(f"Task: {item.task} (Priority: {item.priority})") +``` + +## Why use Iterators? + +### 1. Synchronous Iteration & Network Buffering +Even in a simple synchronous loop, using an iterator provides a significant performance boost. When you use `yield from emulate()`, OpenHosta starts yielding objects as soon as the LLM finishes a block (e.g., one item in a list). + +While your code is processing the first item, the LLM is already streaming the next tokens into your **network socket buffer**. You don't "waste" time waiting for the entire response to be downloaded; you process it piece by piece as it arrives. + +```python +def generate_ideas(topic: str) -> Iterator[str]: + """Yields creative ideas about the topic.""" + yield from emulate() + +# The loop starts as soon as the first idea is fully received! +for idea in generate_ideas("Future of Transportation"): + print(f"Processing: {idea}") + # During this print, the next idea is already being buffered by the OS +``` + +### 2. Asynchronous Iteration & Task Parallelism +Asynchronous iteration (`async for`) is used when you want **true concurrency**. This is particularly powerful in two cases: +* **Chained AI Tasks**: You can start an expensive AI task (like cost estimation) on the first item while the generator is still waiting for the next items from the LLM. +* **Local Models (Torch/CUDA)**: If you are running local models, `asyncio` allows one model to be computing on the GPU/CPU while another part of your code handles I/O or prepares the next request. + +```python +async def generate_ideas(topic: str) -> AsyncIterator[str]: + """Yields creative ideas about the topic.""" + # Option 1: Direct delegation (preferred) + async for idea in emulate_async(): + yield idea + +# Option 2: Return the generator directly +async def generate_ideas_alt(topic: str) -> AsyncIterator[str]: + return emulate_async() + +async def estimate_cost(idea: str) -> float: + """AI task to estimate cost.""" + return await emulate_async() + +async def process_topic(topic: str): + # Both tasks can overlap! + # generate_ideas yields an idea -> we start estimate_cost -> + # estimate_cost waits for its API response -> generate_ideas continues in parallel. + async for idea in generate_ideas(topic): + cost = await estimate_cost(idea) + print(f"Idea: {idea} | Cost: ${cost}M") +``` + +> [!TIP] +> Your interpretation is correct: using `async def` allows the execution of `estimate_cost` to overlap with the ongoing generation of `generate_ideas`. In the case of local models (Torch), this allows the system to utilize computing resources (like the GPU) for one task while the other is managing data or waiting for the next generation step. + +- **Atomicity:** Items are yielded only when their corresponding code block is fully received. For very large objects, there might still be a delay. +- **Rate Limits:** Streaming uses the SSE (Server-Sent Events) API of the model provider. Ensure your provider and API key support streaming. +- **Context Detection:** `emulate()` detects the generator context automatically. You **must** use the `yield` (or `yield from`) keyword in your function body for this to work. diff --git a/pyproject.toml b/pyproject.toml index d18fa90f..7f323f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "OpenHosta" -version = "4.1.0" +version = "4.2.0" description = "A lightweight library integrating LLM natively into Python" keywords = ["AI", "GPT", "Natural language", "Autommatic", "Easy"] authors = [ diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index 917fe5d4..a8d5873f 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.1.0" +__version__ = "4.2.0" from .defaults import config from .defaults import reload_dotenv diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index ff7617be..c72b6fa5 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -208,6 +208,26 @@ def _unwrap_iterator_type(annotation: Any) -> Any: return str +def _is_iterator_type(annotation: Any) -> bool: + from typing import get_origin + import collections.abc + origin = get_origin(annotation) + _iter_origins = ( + collections.abc.Iterator, + collections.abc.Iterable, + collections.abc.AsyncIterator, + collections.abc.AsyncIterable, + collections.abc.Generator, + collections.abc.AsyncGenerator, + ) + if origin in _iter_origins: + return True + + if hasattr(annotation, "__name__") and annotation.__name__ in ("Iterator", "Iterable", "AsyncIterator", "AsyncIterable", "Generator", "AsyncGenerator"): + return True + + return False + def hosta_analyze(frame=None, function_pointer=None) -> AnalyzedFunction: try: if frame is not None: @@ -270,7 +290,12 @@ def hosta_analyze(frame=None, function_pointer=None) -> AnalyzedFunction: else: is_async = inspect.iscoroutinefunction(function_pointer) is_generator = inspect.isgeneratorfunction(function_pointer) or inspect.isasyncgenfunction(function_pointer) - + + if not is_generator and _is_iterator_type(result_return_type): + # The user didn't use 'yield' but the return type is an iterator. + # We promote the function to a generator mode so emulate() returns a stream. + is_generator = True + item_type = _unwrap_iterator_type(result_return_type) if is_generator else result_return_type return AnalyzedFunction( diff --git a/src/OpenHosta/core/uncertainty.py b/src/OpenHosta/core/uncertainty.py index 16896379..c4b368ed 100644 --- a/src/OpenHosta/core/uncertainty.py +++ b/src/OpenHosta/core/uncertainty.py @@ -15,24 +15,67 @@ def _trim_logp_list(logp_list): """ - Remove special control tokens from a VLLM logprob list. + Remove special control tokens and code block markers from a logprob list. Returns the trimmed list. """ - # Helper to find first occurrence index of a token, if present - def _first_index(token): - tokens = [t['token'] for t in logp_list] - return min((i for i, t in enumerate(tokens) if t == token), default=None) - - # Jump to the first token after <|message|> - if "<|message|>" in [t['token'] for t in logp_list]: - last_message_index = max(i for i, t in enumerate(logp_list) if t['token'] == "<|message|>") - logp_list = logp_list[last_message_index + 1:] - - # Cut off at the first occurrence of each control token, if present - for ctrl_token in ("<|return|>", "<|im_end|>", "<|end▁of▁sentence|>"): - idx = _first_index(ctrl_token) - if idx is not None: - logp_list = logp_list[:idx] + # 1. Skip control tokens at the beginning + start_idx = 0 + for i, t in enumerate(logp_list): + if t['token'] in ("<|message|>", " ", "\n"): + continue + start_idx = i + break + + logp_list = logp_list[start_idx:] + + # 2. Jump over ```python if present + full_text = "".join([t['token'] for t in logp_list]) + if full_text.startswith("```python"): + # Find the index of the token that completes "```python" + current_text = "" + for i, t in enumerate(logp_list): + current_text += t['token'] + if "```python" in current_text: + # We skip everything up to the next newline or space after ```python + for j in range(i + 1, len(logp_list)): + if "\n" in logp_list[j]['token']: + logp_list = logp_list[j+1:] + break + break + elif full_text.startswith("```"): + # Jump over ``` + current_text = "" + for i, t in enumerate(logp_list): + current_text += t['token'] + if "```" in current_text: + for j in range(i + 1, len(logp_list)): + if "\n" in logp_list[j]['token']: + logp_list = logp_list[j+1:] + break + break + + # 3. Cut off at control tokens or trailing ``` + end_idx = len(logp_list) + current_text = "" + for i, t in enumerate(logp_list): + if t['token'] in ("<|return|>", "<|im_end|>", "<|end▁of▁sentence|>"): + end_idx = i + break + + current_text += t['token'] + if "```" in current_text and i > 2: # Avoid matching the starting one if it wasn't trimmed + end_idx = i - (len(t['token']) - t['token'].find("```")) # Stop just before ``` + # Actually simpler: find where ``` starts + # But tokens might be " ```" or "```\n" + # We just stop here. + end_idx = i + break + + logp_list = logp_list[:end_idx] + + # 4. Trim trailing newlines/spaces + while logp_list and logp_list[-1]['token'].strip() == "": + logp_list.pop() return logp_list diff --git a/src/OpenHosta/exec/emulate.py b/src/OpenHosta/exec/emulate.py index 1d6d6abb..81cbcdf0 100644 --- a/src/OpenHosta/exec/emulate.py +++ b/src/OpenHosta/exec/emulate.py @@ -51,7 +51,7 @@ def emulate( return pipeline.execute(inspection, force_llm_args) -async def emulate_async( +def emulate_async( *, pipeline: Optional[OneTurnConversationPipeline] = config.DefaultPipeline, force_llm_args: Optional[dict] = {}, @@ -64,7 +64,9 @@ async def emulate_async( - ``async def f() -> T: return await emulate_async()`` Asynchronous value — awaits the LLM response and returns the resolved value. - - ``async def f() -> AsyncIterator[T]: yield emulate_async()`` + - ``async def f() -> AsyncIterator[T]: + async for x in emulate_async(): + yield x`` Async generator — streams items via the async LLM API. Args: @@ -81,8 +83,8 @@ async def emulate_async( item_type = inspection.analyse.item_type if is_generator: - # Return an async generator; the caller does `async for x in await emulate_async(): yield x` + # Return an async generator object return pipeline.execute_stream_async(inspection, force_llm_args, item_type) else: - # Existing behaviour — async single value - return await pipeline.execute_async(inspection, force_llm_args) + # Return a coroutine object + return pipeline.execute_async(inspection, force_llm_args) diff --git a/tests/functionnal/test_func_emulate_generator.py b/tests/functionnal/test_func_emulate_generator.py index 313775c4..a7d9f479 100644 --- a/tests/functionnal/test_func_emulate_generator.py +++ b/tests/functionnal/test_func_emulate_generator.py @@ -35,7 +35,7 @@ async def generate_vowels() -> AsyncIterator[str]: """ Generate the 5 basic vowels (a, e, i, o, u) one by one. """ - async for vowel in await emulate_async(): + async for vowel in emulate_async(): yield vowel results = [] diff --git a/tests/functionnal/test_safe.py b/tests/functionnal/test_safe.py index 46a7c2c4..232e7b46 100644 --- a/tests/functionnal/test_safe.py +++ b/tests/functionnal/test_safe.py @@ -133,8 +133,8 @@ def get_next_step(command: str) -> NextStep: print(f"Final safe context 1 uuid: {s1.uuid} with: {s1.cumulated_uncertainty}/{s1.acceptable_cumulated_uncertainty}") print(f"Final safe context 2 uuid: {s2.uuid} with: {s2.cumulated_uncertainty}/{s2.acceptable_cumulated_uncertainty}") - assert next_step0 is NextStep.GIT_PUSH, f"Expected 'git push' in response, got: {next_step}" - assert next_step1 is NextStep.OTHER, f"Expected 'git push' in response, got: {next_step}" + assert next_step0 is NextStep.GIT_PUSH, f"Expected 'git push' in response, got: {next_step0}" + assert next_step1 is NextStep.OTHER, f"Expected 'other' in response, got: {next_step1}" diff --git a/tests/manual/base_functions.py b/tests/manual/base_functions.py index 2dfb5107..ffc8158c 100644 --- a/tests/manual/base_functions.py +++ b/tests/manual/base_functions.py @@ -26,6 +26,24 @@ from OpenHosta import ask ask("hello world!") + +from OpenHosta import ask_stream +for line in ask_stream("raconte une histoire"): + print(line, end='') + +from OpenHosta import emulate +from typing import Iterator +def answer_step_by_step(question:str) -> Iterator[str]: + """ + Answer a question step by step, yielding each step as a string. + + Start with reformulation, then hypothesis, then calculation steps if needed, then conclusion. + """ + yield from emulate() + +for step in answer_step_by_step("how to calculate the speed of a falling leave on earth"): + print(step) + from OpenHosta import closure, closure_async increment=closure("add one to this number") From 8c91a3a1bc70ab453be62e958cac925457d75078 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 17:53:12 +0200 Subject: [PATCH 03/33] cicd: gpt-4.1 is becoming very slow. use a better test for uncertainty as gpt is more and more confident... --- docs/doc.md | 2 +- tests/functionnal/test_emulate.py | 2 +- tests/functionnal/test_safe.py | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/doc.md b/docs/doc.md index 290508ae..85461990 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -1,6 +1,6 @@ # OpenHosta Documentation Hub -Documentation for version: **4.1** +Documentation for version: **4.2** Welcome to the **OpenHosta** documentation hub. Here you'll find everything you need to leverage Large Language Models (LLMs) natively within your Python projects. OpenHosta transforms human language and semantic structures into pure, executable Python functions. diff --git a/tests/functionnal/test_emulate.py b/tests/functionnal/test_emulate.py index 1f92f8d8..09717397 100644 --- a/tests/functionnal/test_emulate.py +++ b/tests/functionnal/test_emulate.py @@ -328,7 +328,7 @@ def answer_one()->int: """ return emulate() - _LIMIT_PER_CALL=1 + _LIMIT_PER_CALL=2 _LOOP_SIZE=2 t0 = time.time() for i in range(_LOOP_SIZE): diff --git a/tests/functionnal/test_safe.py b/tests/functionnal/test_safe.py index 232e7b46..7b6ef08a 100644 --- a/tests/functionnal/test_safe.py +++ b/tests/functionnal/test_safe.py @@ -319,9 +319,9 @@ def IsThisInThat(this_description:str, that_description:str)->Bool: """ return emulate() - with safe(acceptable_cumulated_uncertainty=math.exp(-5)): + with safe(acceptable_cumulated_uncertainty=math.exp(-5)) as safe_context: ret = IsThisInThat("the sun", "the sky on a clear day") - + assert ret is Bool.TRUE, f"Expected TRUE for sky in clear day, got: {ret}" ret = IsThisInThat("finger", "hand") @@ -331,13 +331,14 @@ def IsThisInThat(this_description:str, that_description:str)->Bool: assert ret is Bool.FALSE, f"Expected FALSE for hand in finger, got: {ret}" try: - ret = IsThisInThat("red ball", "my hand") + ret = IsThisInThat("train 42535", "Paris Train station") except UncertaintyError as e: print(f"Caught expected UncertaintyError due to uncertainty: {e}") ret = None - assert ret is None, f"Expected None for red ball in hand due to uncertainty error, got: {ret}" + assert ret is None, f"Expected None for train in station due to uncertainty error, got: {ret}" + print(safe_context) def test_safe_workflow_organ_location(): From 81b07ab0979a4244fb6dedb06a2c1e84c3c64fc2 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 17:55:51 +0200 Subject: [PATCH 04/33] fix: simple test for Iterator --- tests/manual/base_functions.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/manual/base_functions.py b/tests/manual/base_functions.py index ffc8158c..a38dcaa3 100644 --- a/tests/manual/base_functions.py +++ b/tests/manual/base_functions.py @@ -44,6 +44,31 @@ def answer_step_by_step(question:str) -> Iterator[str]: for step in answer_step_by_step("how to calculate the speed of a falling leave on earth"): print(step) +def split_into_paragraphs(text: str) -> Iterator[str]: + """ + Analyzes the input text and yields each paragraph as a separate string. + Each paragraph should be a coherent unit of thought. + """ + yield from emulate() + +long_text = "... some very long text ..." + +for paragraph in split_into_paragraphs(long_text): + print(f"--- Received Paragraph ---\n{paragraph}\n") + + +def split_into_paragraphs2(text: str) -> Iterator[str]: + """ + Analyzes the input text and yields each paragraph as a separate string. + Each paragraph should be a coherent unit of thought. + """ + return emulate() + +long_text = "... some very long text ..." + +for paragraph in split_into_paragraphs2(long_text): + print(f"--- Received Paragraph ---\n{paragraph}\n") + from OpenHosta import closure, closure_async increment=closure("add one to this number") From 38ce0ae7217252aaf0c5ede44202341afe43de16 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 22:09:26 +0200 Subject: [PATCH 05/33] feat: add dataclass support to generator emulation and update analyzer exception handling --- src/OpenHosta/core/analizer.py | 2 +- .../test_func_emulate_generator.py | 23 ++++++++++++++ tests/manual/local.py | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 tests/manual/local.py diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index c72b6fa5..2c9240d9 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -342,7 +342,7 @@ def _collect_types(p_type, type_list, seen_types=None): try: guarded_type = TypeResolver.resolve(p_type) except: - return + pass # 2. Si c'est un type complexe, on l'ajoute à la liste de documentation type_name = nice_type_name(p_type) diff --git a/tests/functionnal/test_func_emulate_generator.py b/tests/functionnal/test_func_emulate_generator.py index a7d9f479..b49716fe 100644 --- a/tests/functionnal/test_func_emulate_generator.py +++ b/tests/functionnal/test_func_emulate_generator.py @@ -46,3 +46,26 @@ async def generate_vowels() -> AsyncIterator[str]: vowels = run(app()) assert len(vowels) >= 3, "Expected at least 3 vowels generated" assert "a" in vowels, "Missing 'a'" + +def test_emulate_generator_dataclass(): + from dataclasses import dataclass + + @dataclass + class President: + name: str + start_date: str + end_date: str + + def list_presidents() -> Iterator[President]: + """ + List 2 presidents of France. + """ + yield from emulate() + + items = list(list_presidents()) + assert len(items) >= 1 + for item in items: + assert isinstance(item, President) + assert hasattr(item, "name") + assert hasattr(item, "start_date") + assert hasattr(item, "end_date") diff --git a/tests/manual/local.py b/tests/manual/local.py new file mode 100644 index 00000000..68716c8a --- /dev/null +++ b/tests/manual/local.py @@ -0,0 +1,31 @@ +from OpenHosta import ask_stream, print_last_prompt + +from OpenHosta import config + +config.DefaultModel.api_parameters |= {"extra_body" : {"enable_thinking": False}, "reasoning_effort": "low"} + +for line in ask_stream("liste les présidents de la france"): + print("--->", line, end="", flush=True) + +from typing import Iterator +from OpenHosta import emulate +from dataclasses import dataclass + +@dataclass +class President: + name: str + start_date: str + end_date: str + +def list_all(what:str) -> Iterator[dict[str,str]]: + """ + Iterate over all the items of a given type. + :param what: The type of items to iterate over. + :return: An iterator over the items of the given type. + """ + return emulate() + +for p in list_all("présidents de la france"): + print(p) + +print_last_prompt(list_all) \ No newline at end of file From aeb500f82e07dede5c42db759cced54ebd7768fe Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 22:13:00 +0200 Subject: [PATCH 06/33] feat: initialize and update inspection logs with streaming response content and data items --- src/OpenHosta/pipelines/simple_pipeline.py | 27 +++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/OpenHosta/pipelines/simple_pipeline.py b/src/OpenHosta/pipelines/simple_pipeline.py index 29d5e634..7f779289 100644 --- a/src/OpenHosta/pipelines/simple_pipeline.py +++ b/src/OpenHosta/pipelines/simple_pipeline.py @@ -601,11 +601,17 @@ async def execute_async(self, inspection: Inspection, force_llm_args: dict) -> A def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type): """Stream typed items from the LLM, one ```python block per item.""" + inspection.logs["rational"] = "" + inspection.logs["answer"] = "" + inspection.logs["response_string"] = "" + inspection.logs["response_data"] = [] + messages = self.push_streaming(inspection, item_type) llm_args = inspection.force_llm_args | force_llm_args buffer = "" for chunk in inspection.model.generate_stream(messages, **llm_args): + inspection.logs["answer"] += chunk buffer += chunk # Extract as many complete blocks as possible from the buffer while True: @@ -613,6 +619,7 @@ def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type if content is None: break buffer = remainder + inspection.logs["response_string"] += f"```python\n{content}\n```\n" typed_item = self._pull_single_item(inspection, content, item_type) if typed_item is not None: yield typed_item @@ -623,6 +630,7 @@ def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type probe = buffer if buffer.rstrip().endswith("```") else buffer + "\n```" content, _ = self._extract_next_code_block(probe) if content is not None: + inspection.logs["response_string"] += f"```python\n{content}\n```\n" typed_item = self._pull_single_item(inspection, content, item_type) if typed_item is not None: yield typed_item @@ -630,17 +638,24 @@ def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type async def execute_stream_async(self, inspection: Inspection, force_llm_args: dict, item_type): """Async version of _execute_stream.""" + inspection.logs["rational"] = "" + inspection.logs["answer"] = "" + inspection.logs["response_string"] = "" + inspection.logs["response_data"] = [] + messages = self.push_streaming(inspection, item_type) llm_args = inspection.force_llm_args | force_llm_args buffer = "" async for chunk in inspection.model.generate_stream_async(messages, **llm_args): + inspection.logs["answer"] += chunk buffer += chunk while True: content, remainder = self._extract_next_code_block(buffer) if content is None: break buffer = remainder + inspection.logs["response_string"] += f"```python\n{content}\n```\n" typed_item = self._pull_single_item(inspection, content, item_type) if typed_item is not None: yield typed_item @@ -649,6 +664,7 @@ async def execute_stream_async(self, inspection: Inspection, force_llm_args: dic probe = buffer if buffer.rstrip().endswith("```") else buffer + "\n```" content, _ = self._extract_next_code_block(probe) if content is not None: + inspection.logs["response_string"] += f"```python\n{content}\n```\n" typed_item = self._pull_single_item(inspection, content, item_type) if typed_item is not None: yield typed_item @@ -663,10 +679,19 @@ def _pull_single_item(self, inspection: Inspection, raw_str: str, item_type) -> """ original_type = inspection.analyse.type inspection.analyse.type = item_type + + # Save the current list because pull_type_data_section will overwrite response_data + response_data_list = inspection.logs.get("response_data", []) + if not isinstance(response_data_list, list): + response_data_list = [] + try: - return self.pull_type_data_section(inspection, raw_str.strip()) + item = self.pull_type_data_section(inspection, raw_str.strip()) + response_data_list.append(item) + return item except (ValueError, TypeError, SyntaxError) as e: print(f"[execute_stream] Skipping unparseable item: {e!r} — raw: {raw_str!r}") return None finally: inspection.analyse.type = original_type + inspection.logs["response_data"] = response_data_list From bd425e57993f2b5892c6e5c5443bb18209a96e61 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 22:46:55 +0200 Subject: [PATCH 07/33] feat: replace BatchDataContext with gather_data utility for declarative parallelism and add documentation for meta-prompt improvements --- docs/examples/parallel_processing.md | 39 ++-- docs/index.md | 6 +- next/meta_prompt_improvements.md | 284 +++++++++++++++++++++++++++ src/OpenHosta/utils/gather_data.py | 95 +++++++++ 4 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 next/meta_prompt_improvements.md create mode 100644 src/OpenHosta/utils/gather_data.py diff --git a/docs/examples/parallel_processing.md b/docs/examples/parallel_processing.md index 5407947d..47b0307f 100644 --- a/docs/examples/parallel_processing.md +++ b/docs/examples/parallel_processing.md @@ -47,13 +47,13 @@ if __name__ == "__main__": asyncio.run(main()) ``` -## 2. The BatchDataContext Manager (Coming soon) +## 2. Declarative Parallelism with gather_data -OpenHosta provides an elegant context manager under development `BatchDataContext` designed exclusively to simplify multi-processing workloads without manually tampering with event loops or explicit tasks. +OpenHosta provides an elegant, explicit data processor called gather_data (and its async counterpart gather_data_async). This approach simplifies multi-processing workloads without manually tampering with event loops or explicit tasks. It scans standard Python data structures and resolves any pending asynchronous calls in-place. ```python from OpenHosta import emulate_async -from OpenHosta import BatchDataContext +from OpenHosta import gather_data async def name_list(topic: str) -> list[str]: """Generates three names related to the topic.""" @@ -67,17 +67,26 @@ async def alt_name(person: str) -> str: """Returns an alternative alias of the person.""" return await emulate_async() -# Batch size allows processing N queries in a sliding window -with BatchDataContext(batch_size=10) as my_data: - # Case A: Function inherently returning a list - my_data["A"] = name_list("Macron") - - # Case B: Python Array encapsulating multiple separate Async Coroutines - my_data["B"] = [first_name("Trump"), alt_name("Trump")] - - # Case C: Inserting strict generic static data - my_data["C"] = "Static Data" +# Create a standard Python dictionary +my_data = {} + +# Case A: Function inherently returning a list +my_data["A"] = name_list("Macron") + +# Case B: Python Array encapsulating multiple separate Async Coroutines +my_data["B"] = [first_name("Trump"), alt_name("Trump")] -# Block closes, wait occurs, then dictionary items are completely resolved! -print("Résultat final :", my_data) +# Case C: Inserting strict generic static data +my_data["C"] = "Static Data" + +# Resolves all pending coroutines in-place. +# Batch size allows processing N queries in a sliding window to prevent rate limits. +gather_data(my_data, batch_size=10, max_delay=120) + +# After the function call, dictionary items are completely resolved! +print("Final Result :", my_data) +# Output: {'A': ['Emmanuel', 'Jean', ...], 'B': ['Donald', 'The Don'], 'C': 'Static Data'} ``` + +> Note for Async Environments (FastAPI, Jupyter): If you are running inside an existing event loop, use the asynchronous version: `await gather_data_async(my_data, batch_size=10)`. + diff --git a/docs/index.md b/docs/index.md index 878c4517..414a9b68 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # OpenHosta Documentation -**Version 4.1** · [GitHub](https://github.com/hand-e-fr/OpenHosta) · [PyPI](https://pypi.org/project/OpenHosta/) +**Version 4.2** · [GitHub](https://github.com/hand-e-fr/OpenHosta) · [PyPI](https://pypi.org/project/OpenHosta/) Welcome to the **OpenHosta** documentation. OpenHosta is the semantic layer for Python — it transforms human language and type annotations into executable, type-safe Python functions powered by Large Language Models. @@ -14,6 +14,9 @@ Set up your environment, configure a local or remote model, and run your first ` ### ⚙️ [Core Functions](core_functions.md) Learn about `emulate`, `emulate_async`, `emulate_iterator`, `closure`, `ask`, and `test`. +### 🔄 [Streaming & Iterators](streaming.md) +Stream raw tokens with `ask_stream`, or yield structured Python objects one-by-one with `Iterator` return types and `emulate_iterator`. + ### 🔧 [Models & Setup](models_and_setup.md) Connect any OpenAI-compatible endpoint (Ollama, vLLM, Azure OpenAI), customize prompts, enable audit mode, and track costs. @@ -36,6 +39,7 @@ Deep dive into OpenHosta's type validation and conversion system with configurab | 🗃️ [Data Extraction](examples/data_extraction.md) | Populate `dataclass` / `Pydantic` from unstructured text | | 👁️ [Local OCR](examples/ocr_local_ollama.md) | Image processing with `PIL.Image` + Ollama | | ⚡ [Parallel Processing](examples/parallel_processing.md) | Async batch workloads with `emulate_async` | +| 🔄 [Streaming & Iterators](streaming.md) | Stream tokens or yield typed objects with `Iterator` | --- diff --git a/next/meta_prompt_improvements.md b/next/meta_prompt_improvements.md new file mode 100644 index 00000000..f7077a96 --- /dev/null +++ b/next/meta_prompt_improvements.md @@ -0,0 +1,284 @@ +# Meta-Prompt Improvement Suggestions + +> Analysis of [meta_prompt.py](file:///home/ebatt/VSCode_GitRepos/OpenHosta.git/src/OpenHosta/core/meta_prompt.py) and the full push/pull pipeline. + +--- + +## Current prompt (condensed) + +``` +You will act as a simulator for functions that cannot be implemented in actual code. +I'll provide you with function definitions described in Python syntax. … +Instead, imagine a realistic or reasonable output that matches the function description. + +Here's the function definition: +{{ python_type_definition_dict }} + +def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: + """{{ function_doc }}""" + ...behavior to be simulated... + return ...appropriate return value... + +OUTPUT FORMAT: +Respond with only the return value, placed inside a single fenced Python code block. +``` + +--- + +## Category 1 — Role Framing & Structural Clarity + +### 1. Tighten the "simulator" identity + +**Problem:** The current preamble says *"imagine a realistic or reasonable output"*. This gives the LLM permission to be creative rather than deterministic, which increases retry rates for factual functions. + +**Suggestion:** Split the role instruction into two stances — *factual* vs *creative* — controlled by a template variable: + +```jinja2 +{% if factual_mode %} +You simulate a Python function. Your output MUST be factually accurate, verifiable data. +If you are unsure, use the most widely accepted answer. Do not invent facts. +{% else %} +You simulate a Python function. Your output should be realistic and plausible, +consistent with the function's docstring description. +{% endif %} +``` + +The `factual_mode` flag could be inferred automatically from the docstring (presence of keywords like "list", "capital of", "translate") or set via `force_template_data`. + +**Impact:** Reduces hallucination on knowledge-retrieval tasks, cuts retry count. + +--- + +### 2. Move the type definitions AFTER the function signature + +**Problem:** Currently `{{ python_type_definition_dict }}` is rendered *before* the function signature. The LLM reads the type docs (e.g. a dataclass definition) without knowing which function they relate to. This hurts context grounding, especially with multiple nested types. + +**Current order:** +``` +type definitions ← reader doesn't yet know why these matter +def my_func(…) +``` + +**Suggested order:** +``` +def my_func(…) -> ReturnType: + """docstring""" + +# Type definitions used above: +{{ python_type_definition_dict }} +``` + +This mirrors how a developer reads code: signature first, then look up types. + +**Impact:** Better type compliance, especially for nested dataclasses. + +--- + +### 3. Add an explicit "contract" section + +**Problem:** The system prompt mixes role, type docs, output format, and examples in a flat flow. LLMs (especially newer reasoning models) perform better with clearly delineated sections. + +**Suggestion:** +```jinja2 +## ROLE +You simulate Python functions... + +## FUNCTION +```python +def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: + """{{ function_doc }}""" +``` + +## TYPE DEFINITIONS +{{ python_type_definition_dict }} + +## OUTPUT CONTRACT +{{ output_format_block }} + +{% if examples_database %} +## EXAMPLES +{{ examples_database }} +{% endif %} +``` + +Using markdown headers gives reasoning models explicit section boundaries to attend to. + +**Impact:** Cleaner attention patterns, easier to extend/override individual sections. + +--- + +## Category 2 — Output Format Control + +### 4. Type-specific output examples (biggest win) + +**Problem:** The current `OUTPUT FORMAT` section gives a single generic example (`"your answer here"` for `str`). For complex types (dataclass, list[dataclass], dict, tuple), the LLM has to *guess* the expected serialization format. This is the #1 source of parse failures. + +**Suggestion:** Generate a type-specific example automatically from the GuardedType. The `_type_py_repr` already contains a structural description; we just need to produce a *populated instance* example. + +```python +# In analizer.py, new function: +def generate_output_example(p_type) -> str: + """Generate a concrete example value for the return type.""" + guarded = TypeResolver.resolve(p_type) + if hasattr(guarded, '_type_py') and is_dataclass(guarded._type_py): + # Build a placeholder dict from field names + fields = dataclasses.fields(guarded._type_py) + example = {f.name: _example_for_type(f.type) for f in fields} + return f"{guarded._type_py.__name__}({', '.join(f'{k}={v!r}' for k,v in example.items())})" + ... +``` + +Then in the template: +```jinja2 +Example for return type `{{ function_return_type_name }}`: +```python +{{ output_example }} +``` +``` + +**Impact:** Dramatically reduces parse errors for structured types. The `pull_extract_data_section` code already handles both `ClassName(...)` and `{...}` formats, so either example works. + +--- + +### 5. Explicitly forbid wrapping / preamble patterns + +**Problem:** Many models add preamble text like *"Here is the result:"* or *"Based on the function..."* before the code block. The `pull_extract_data_section` strips trailing code blocks but chokes on *leading* prose when the code block isn't the last thing in the response. + +**Suggestion:** Add an emphatic negative instruction: + +```jinja2 +## OUTPUT CONTRACT +Your entire response MUST be a single fenced Python code block. Nothing else. +Do NOT include any text, explanation, markdown headers, or commentary before or after the block. + +CORRECT: +```python +42 +``` + +WRONG: +Here is the result: +```python +42 +``` +``` + +Also consider adding a "canary" check: if `pull_extract_data_section` finds text before the *first* ````python`, it should log a warning and try to extract the *first* (not last) code block instead. + +**Impact:** Reduces the "known gap" documented in [emulate_pipeline.md L274-276](file:///home/ebatt/VSCode_GitRepos/OpenHosta.git/docs/emulate_pipeline.md#L274-L276). + +--- + +### 6. Unify constructor vs dict format for structured types + +**Problem:** For dataclasses and Pydantic models, the LLM sometimes returns a constructor call (`Person(name="Alice", age=30)`) and sometimes returns a dict (`{"name": "Alice", "age": 30}`). Both are parseable, but dict is safer because `ast.literal_eval` can parse it directly without needing to handle `ast.Call` trees. + +**Suggestion:** Explicitly tell the LLM which format to use: + +```jinja2 +{% if is_structured_return %} +Return the value as a Python dict literal (NOT a constructor call). +Example: +```python +{"name": "Alice", "age": 30} +``` +{% endif %} +``` + +The `is_structured_return` flag can be set when `analyse.type` resolves to a dataclass, Pydantic model, or TypedDict. + +**Impact:** Simplifies `_parse_heuristic` code paths and makes the pipeline more robust to model differences. + +--- + +## Category 3 — Type Documentation Quality + +### 7. Include field descriptions from docstrings / Pydantic `Field(description=...)` + +**Problem:** The `python_type_definition_dict` for dataclasses only shows field names and types: + +```python +@dataclass +class President: + name: str + start_date: str + end_date: str +``` + +But for Pydantic models, the `describe()` function in `subclassablepydantic.py` already extracts rich field descriptions, aliases, required/optional markers, and examples. This discrepancy means the LLM gets less context for dataclasses than for Pydantic models. + +**Suggestion:** Enrich `guarded_dataclass` repr to include: +- Field docstrings (from `__doc__` on the class, parsed per-field, or from `metadata`) +- Default values when present +- Type constraints (e.g. `int` with a known range) + +```python +# Enhanced _type_py_repr for dataclasses: +@dataclass +class President: + name: str # Full name of the president + start_date: str # Format: YYYY-MM-DD + end_date: str # Format: YYYY-MM-DD, or "incumbent" +``` + +**Impact:** Better field-level accuracy, especially for date/format-sensitive fields. + +--- + +### 8. Show enum values inline in function signature for simple enums + +**Problem:** For enum return types, the enum definition appears in `python_type_definition_dict`, which is far from the function signature. The LLM sometimes "forgets" the allowed values. + +**Suggestion:** For enums with ≤ 8 members, inline the values directly in the function signature: + +```jinja2 +def classify_sentiment(text: str) -> Sentiment: # Sentiment ∈ {POSITIVE, NEGATIVE, NEUTRAL} +``` + +The full definition can still appear in the type definitions section for larger enums. + +**Impact:** Higher enum compliance, fewer `_parse_heuristic` fallbacks. + +--- + +### 9. Add `{{ parameter_constraints }}` for value-range hints + +**Problem:** The user's docstring is the only source of parameter semantics. But often the *value* itself carries implicit constraints (e.g. a `year: int` parameter receiving `2024` implies the domain is years, not arbitrary integers). The LLM doesn't know this. + +**Suggestion:** Add an optional `parameter_constraints` variable that can be auto-generated or manually injected: + +```jinja2 +{% if parameter_constraints %} +## PARAMETER CONSTRAINTS +{{ parameter_constraints }} +{% endif %} +``` + +Auto-generation heuristics: +- `int` value > 1900 and < 2100 → likely a year +- `str` value matching ISO date pattern → note "date in ISO format" +- `float` value between 0 and 1 → likely a probability or ratio + +**Impact:** Better contextual grounding for the LLM's simulation. + +--- + +## Summary — Priority Ranking + +| # | Suggestion | Effort | Impact | Priority | +|---|---|---|---|---| +| 4 | Type-specific output examples | Medium | 🔴 High | **P0** | +| 5 | Forbid wrapping/preamble | Low | 🔴 High | **P0** | +| 2 | Type defs after signature | Low | 🟡 Medium | **P1** | +| 6 | Unify constructor vs dict format | Low | 🟡 Medium | **P1** | +| 3 | Explicit section headers | Low | 🟡 Medium | **P1** | +| 1 | Factual vs creative mode | Low | 🟡 Medium | **P2** | +| 7 | Field descriptions for dataclasses | Medium | 🟡 Medium | **P2** | +| 8 | Inline enum values | Low | 🟢 Low-Med | **P2** | +| 9 | Parameter constraints | Medium | 🟢 Low | **P3** | + +> [!TIP] +> Items 4+5 alone would likely eliminate most of the parse failures and retries. They're also low-risk since the `pull` pipeline already handles the formats they'd produce. + +> [!IMPORTANT] +> Any prompt change should be A/B tested against the existing functional test suite (`tests/typing/` and `tests/functionnal/`) to verify that it doesn't regress on models other than GPT-4o (e.g. local Ollama, DeepSeek). diff --git a/src/OpenHosta/utils/gather_data.py b/src/OpenHosta/utils/gather_data.py new file mode 100644 index 00000000..c5b6657d --- /dev/null +++ b/src/OpenHosta/utils/gather_data.py @@ -0,0 +1,95 @@ +import asyncio +import inspect +from typing import Any, Optional, Union, Dict, List + +class _Placeholder: + """Marqueur interne pour se souvenir d'où vient une coroutine.""" + def __init__(self, index: int): + self.index = index + +def _extract_tasks(obj: Any, tasks: list) -> Any: + """Parcourt récursivement et remplace les coroutines par des Placeholders.""" + if inspect.isawaitable(obj): + idx = len(tasks) + tasks.append(obj) + return _Placeholder(idx) + elif isinstance(obj, list): + return [_extract_tasks(item, tasks) for item in obj] + elif isinstance(obj, tuple): + return tuple(_extract_tasks(item, tasks) for item in obj) + elif isinstance(obj, dict): + return {k: _extract_tasks(v, tasks) for k, v in obj.items()} + return obj + +def _inject_results(obj: Any, results: list) -> Any: + """Parcourt récursivement et remplace les Placeholders par les vrais résultats.""" + if isinstance(obj, _Placeholder): + return results[obj.index] + elif isinstance(obj, list): + for i in range(len(obj)): + obj[i] = _inject_results(obj[i], results) + return obj + elif isinstance(obj, tuple): + return tuple(_inject_results(item, results) for item in obj) + elif isinstance(obj, dict): + for k in obj.keys(): + obj[k] = _inject_results(obj[k], results) + return obj + return obj + +async def gather_data_async(data: Union[Dict, List], batch_size: int = 30, max_delay: Optional[int] = 120): + """Version asynchrone pour FastAPI ou les boucles d'événements existantes.""" + tasks = [] + + # 1. Extraction (modification temporaire in-place) + if isinstance(data, dict): + for k, v in data.items(): + data[k] = _extract_tasks(v, tasks) + elif isinstance(data, list): + for i, v in enumerate(data): + data[i] = _extract_tasks(v, tasks) + else: + raise TypeError("gather_data ne supporte que les dictionnaires ou les listes à la racine.") + + if not tasks: + return data + + # 2. Résolution par lots + all_results = [] + for i in range(0, len(tasks), batch_size): + batch = tasks[i : i + batch_size] + try: + if max_delay: + batch_res = await asyncio.wait_for(asyncio.gather(*batch), timeout=max_delay) + else: + batch_res = await asyncio.gather(*batch) + all_results.extend(batch_res) + except asyncio.TimeoutError: + raise TimeoutError(f"gather_data : Timeout de {max_delay}s dépassé.") + + # 3. Ré-injection in-place + if isinstance(data, dict): + for k in data.keys(): + data[k] = _inject_results(data[k], all_results) + elif isinstance(data, list): + for i in range(len(data)): + data[i] = _inject_results(data[i], all_results) + + return data + +def gather_data(data: Union[Dict, List], batch_size: int = 30, max_delay: Optional[int] = 120): + """ + Version synchrone pour les scripts standards et les débutants. + Modifie l'objet 'data' en place. + """ + try: + loop = asyncio.get_running_loop() + if loop.is_running(): + raise RuntimeError( + "Vous êtes dans un environnement asynchrone. Utilisez 'await gather_data_async(data)'." + ) + except RuntimeError as e: + if "environnement asynchrone" in str(e): + raise e + # Si aucune boucle ne tourne, on en crée une pour exécuter le travail + asyncio.run(gather_data_async(data, batch_size, max_delay)) \ No newline at end of file From 29d0e600df8e9953147533dfdbd0c86af4e02c7a Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 22:59:17 +0200 Subject: [PATCH 08/33] test: add comprehensive unit and functional tests for gather_data and gather_data_async utilities --- src/OpenHosta/asynchrone/batchdatacontext.py | 9 +- tests/asynchrone/test_gather_data.py | 451 +++++++++++++++++++ 2 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 tests/asynchrone/test_gather_data.py diff --git a/src/OpenHosta/asynchrone/batchdatacontext.py b/src/OpenHosta/asynchrone/batchdatacontext.py index a3dde4bf..3286f8ba 100644 --- a/src/OpenHosta/asynchrone/batchdatacontext.py +++ b/src/OpenHosta/asynchrone/batchdatacontext.py @@ -80,9 +80,12 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): if exc_type: return False try: - asyncio.get_running_loop() - raise RuntimeError("Utilisez 'async with' dans un environnement asynchrone (FastAPI).") - except RuntimeError: + loop = asyncio.get_running_loop() + if loop.is_running(): + raise RuntimeError("Utilisez 'async with' dans un environnement asynchrone (FastAPI).") + except RuntimeError as e: + if "environnement asynchrone" in str(e): + raise e asyncio.run(self.data._resolve(self.batch_size, self.max_delay)) # --- Support Async (FastAPI) --- diff --git a/tests/asynchrone/test_gather_data.py b/tests/asynchrone/test_gather_data.py new file mode 100644 index 00000000..a0a3b74e --- /dev/null +++ b/tests/asynchrone/test_gather_data.py @@ -0,0 +1,451 @@ +""" +Tests for OpenHosta.utils.gather_data module. + +Two categories: + 1. Unit tests – plain coroutines (no LLM), fast, isolated. + 2. Functional tests – use emulate_async() with real LLM calls (marked slow). +""" + +import asyncio +import pytest + +from OpenHosta.utils.gather_data import ( + _Placeholder, + _extract_tasks, + _inject_results, + gather_data_async, + gather_data, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def fake_coro(value): + """Trivial awaitable that returns *value*.""" + return value + + +async def slow_coro(seconds: float): + """Awaitable that sleeps before returning.""" + await asyncio.sleep(seconds) + return f"done-{seconds}" + + +# =================================================================== +# 1. UNIT TESTS – _Placeholder +# =================================================================== + +class TestPlaceholder: + def test_stores_index(self): + p = _Placeholder(7) + assert p.index == 7 + + +# =================================================================== +# 2. UNIT TESTS – _extract_tasks +# =================================================================== + +class TestExtractTasks: + def test_plain_value_unchanged(self): + tasks = [] + result = _extract_tasks("hello", tasks) + assert result == "hello" + assert tasks == [] + + def test_awaitable_replaced(self): + tasks = [] + coro = fake_coro(42) + result = _extract_tasks(coro, tasks) + assert isinstance(result, _Placeholder) + assert result.index == 0 + assert len(tasks) == 1 + # cleanup + tasks[0].close() + + def test_list_with_awaitables(self): + tasks = [] + coro1 = fake_coro(1) + coro2 = fake_coro(2) + result = _extract_tasks([coro1, "static", coro2], tasks) + assert isinstance(result, list) + assert isinstance(result[0], _Placeholder) + assert result[1] == "static" + assert isinstance(result[2], _Placeholder) + assert len(tasks) == 2 + for t in tasks: + t.close() + + def test_dict_with_awaitables(self): + tasks = [] + coro = fake_coro("v") + result = _extract_tasks({"k": coro, "s": 1}, tasks) + assert isinstance(result["k"], _Placeholder) + assert result["s"] == 1 + assert len(tasks) == 1 + tasks[0].close() + + def test_tuple_with_awaitables(self): + tasks = [] + coro = fake_coro(99) + result = _extract_tasks((coro, "x"), tasks) + assert isinstance(result, tuple) + assert isinstance(result[0], _Placeholder) + assert result[1] == "x" + tasks[0].close() + + def test_nested_structures(self): + tasks = [] + coro = fake_coro("deep") + result = _extract_tasks({"a": [coro]}, tasks) + assert isinstance(result["a"][0], _Placeholder) + assert len(tasks) == 1 + tasks[0].close() + + +# =================================================================== +# 3. UNIT TESTS – _inject_results +# =================================================================== + +class TestInjectResults: + def test_placeholder_replaced(self): + results = ["alpha", "beta"] + assert _inject_results(_Placeholder(0), results) == "alpha" + assert _inject_results(_Placeholder(1), results) == "beta" + + def test_plain_value_unchanged(self): + assert _inject_results(42, []) == 42 + + def test_list_injection(self): + results = [10, 20] + data = [_Placeholder(0), "fixed", _Placeholder(1)] + out = _inject_results(data, results) + assert out == [10, "fixed", 20] + + def test_dict_injection(self): + results = ["resolved"] + data = {"key": _Placeholder(0), "other": "static"} + out = _inject_results(data, results) + assert out == {"key": "resolved", "other": "static"} + + def test_tuple_injection(self): + results = [100] + data = (_Placeholder(0), "x") + out = _inject_results(data, results) + assert isinstance(out, tuple) + assert out == (100, "x") + + def test_nested_injection(self): + results = ["deep_val"] + data = {"a": [_Placeholder(0)]} + out = _inject_results(data, results) + assert out == {"a": ["deep_val"]} + + +# =================================================================== +# 4. UNIT TESTS – gather_data_async +# =================================================================== + +class TestGatherDataAsync: + + @pytest.mark.asyncio + async def test_dict_simple(self): + data = {"a": fake_coro(10), "b": fake_coro(20)} + result = await gather_data_async(data) + assert result["a"] == 10 + assert result["b"] == 20 + + @pytest.mark.asyncio + async def test_list_simple(self): + data = [fake_coro("x"), fake_coro("y")] + result = await gather_data_async(data) + assert result == ["x", "y"] + + @pytest.mark.asyncio + async def test_dict_nested_list(self): + data = {"items": [fake_coro(1), fake_coro(2), "static"]} + result = await gather_data_async(data) + assert result["items"] == [1, 2, "static"] + + @pytest.mark.asyncio + async def test_dict_nested_dict(self): + data = {"outer": {"inner": fake_coro("nested")}} + result = await gather_data_async(data) + assert result["outer"]["inner"] == "nested" + + @pytest.mark.asyncio + async def test_mixed_static_and_coroutine(self): + data = { + "dynamic": fake_coro("resolved"), + "static": "plain", + "number": 42, + } + result = await gather_data_async(data) + assert result["dynamic"] == "resolved" + assert result["static"] == "plain" + assert result["number"] == 42 + + @pytest.mark.asyncio + async def test_empty_dict(self): + data = {} + result = await gather_data_async(data) + assert result == {} + + @pytest.mark.asyncio + async def test_empty_list(self): + data = [] + result = await gather_data_async(data) + assert result == [] + + @pytest.mark.asyncio + async def test_no_coroutines(self): + data = {"a": 1, "b": "hello"} + result = await gather_data_async(data) + assert result == {"a": 1, "b": "hello"} + + @pytest.mark.asyncio + async def test_batching(self): + """Ensure batching with small batch_size still resolves all.""" + data = [fake_coro(i) for i in range(10)] + result = await gather_data_async(data, batch_size=3) + assert result == list(range(10)) + + @pytest.mark.asyncio + async def test_timeout(self): + data = {"stuck": asyncio.sleep(10)} + with pytest.raises(TimeoutError): + await gather_data_async(data, max_delay=0.1) + + @pytest.mark.asyncio + async def test_no_timeout(self): + """max_delay=None should skip timeout wrapping.""" + data = {"fast": fake_coro("ok")} + result = await gather_data_async(data, max_delay=None) + assert result["fast"] == "ok" + + @pytest.mark.asyncio + async def test_rejects_non_dict_non_list(self): + with pytest.raises(TypeError): + await gather_data_async("not a dict or list") + + @pytest.mark.asyncio + async def test_rejects_int(self): + with pytest.raises(TypeError): + await gather_data_async(123) + + @pytest.mark.asyncio + async def test_modifies_in_place(self): + """The original dict reference should be mutated.""" + data = {"a": fake_coro("val")} + returned = await gather_data_async(data) + assert data is returned + assert data["a"] == "val" + + @pytest.mark.asyncio + async def test_list_modifies_in_place(self): + data = [fake_coro(1), fake_coro(2)] + returned = await gather_data_async(data) + assert data is returned + assert data == [1, 2] + + @pytest.mark.asyncio + async def test_tuple_in_dict(self): + data = {"t": (fake_coro("a"), "b")} + result = await gather_data_async(data) + assert result["t"] == ("a", "b") + assert isinstance(result["t"], tuple) + + @pytest.mark.asyncio + async def test_large_batch(self): + """All 50 coroutines with default batch_size=30 → 2 batches.""" + data = {f"k{i}": fake_coro(i) for i in range(50)} + result = await gather_data_async(data) + for i in range(50): + assert result[f"k{i}"] == i + + +# =================================================================== +# 5. UNIT TESTS – gather_data (sync wrapper) +# =================================================================== + +class TestGatherDataSync: + + def test_dict_simple(self): + data = {"a": fake_coro(10), "b": fake_coro(20)} + gather_data(data) + assert data["a"] == 10 + assert data["b"] == 20 + + def test_list_simple(self): + data = [fake_coro("x"), fake_coro("y")] + gather_data(data) + assert data == ["x", "y"] + + def test_nested_dict_in_list(self): + data = [{"sub": fake_coro("val")}, fake_coro("top")] + gather_data(data) + assert data[0]["sub"] == "val" + assert data[1] == "top" + + def test_batching_sync(self): + data = [fake_coro(i) for i in range(7)] + gather_data(data, batch_size=2) + assert data == list(range(7)) + + def test_timeout_sync(self): + data = {"stuck": asyncio.sleep(10)} + with pytest.raises(TimeoutError): + gather_data(data, max_delay=0.1) + + def test_rejects_bad_type(self): + with pytest.raises(TypeError): + gather_data("oops") + + +# =================================================================== +# 6. SYNC-IN-ASYNC GUARD +# =================================================================== + +class TestSyncInAsyncGuard: + + @pytest.mark.asyncio + async def test_sync_gather_data_raises_in_async_context(self): + """Calling the sync wrapper inside a running loop must raise.""" + with pytest.raises(RuntimeError, match="environnement asynchrone"): + gather_data({"a": fake_coro(1)}) + + +# =================================================================== +# 7. FUNCTIONAL TESTS – with emulate_async() (require LLM) +# =================================================================== + +@pytest.mark.slow +class TestGatherDataWithEmulate: + """ + These tests call real LLM endpoints via emulate_async(). + They require proper .env configuration (API keys, model, etc.). + Run with: pytest -m slow (or without -m to include them) + """ + + def test_gather_data_dict_with_emulate(self): + """ + Mirrors the documentation example from parallel_processing.md § 2. + Builds a dict mixing emulate_async coroutines and static data, + then resolves everything with gather_data (sync). + """ + from OpenHosta import emulate_async + + async def name_list(topic: str) -> list[str]: + """Generates three names related to the topic.""" + return await emulate_async() + + async def first_name(person: str) -> str: + """Returns the first name of the famous person.""" + return await emulate_async() + + async def alt_name(person: str) -> str: + """Returns an alternative alias of the person.""" + return await emulate_async() + + my_data = {} + my_data["A"] = name_list("Macron") + my_data["B"] = [first_name("Trump"), alt_name("Trump")] + my_data["C"] = "Static Data" + + gather_data(my_data, batch_size=10, max_delay=120) + + # A should be a list of strings + assert isinstance(my_data["A"], list), f"Expected list, got {type(my_data['A'])}" + assert len(my_data["A"]) > 0, "name_list should return a non-empty list" + assert all(isinstance(n, str) for n in my_data["A"]), "All items in A should be strings" + + # B should be a list of two strings + assert isinstance(my_data["B"], list) + assert len(my_data["B"]) == 2 + assert all(isinstance(n, str) for n in my_data["B"]), "All items in B should be strings" + + # C is static + assert my_data["C"] == "Static Data" + + def test_gather_data_async_with_emulate(self): + """ + Same scenario but using gather_data_async directly in an async test. + """ + from OpenHosta import emulate_async + import asyncio + + async def capital_of(country: str) -> str: + """Returns the capital city of the given country.""" + return await emulate_async() + + async def app(): + data = { + "france": capital_of("France"), + "germany": capital_of("Germany"), + "static": "untouched", + } + + return await gather_data_async(data, batch_size=5, max_delay=120) + + result = asyncio.run(app()) + + assert "Paris" in result["france"], f"Expected Paris, got: {result['france']}" + assert isinstance(result["germany"], str) + assert len(result["germany"]) > 0 + assert result["static"] == "untouched" + + def test_gather_data_list_with_emulate(self): + """ + Resolve a list of emulate_async coroutines via the sync API. + """ + from OpenHosta import emulate_async + + async def sentiment(text: str) -> str: + """Classify the sentiment of the text as 'positive', 'negative', or 'neutral'.""" + return await emulate_async() + + data = [ + sentiment("I love this product!"), + sentiment("This is terrible."), + sentiment("The weather is okay."), + ] + + gather_data(data, batch_size=10, max_delay=120) + + assert all(isinstance(s, str) for s in data) + assert len(data) == 3 + + def test_gather_data_dataclass_with_emulate(self): + """ + Mirrors the invoice extraction pattern from parallel_processing.md § 1, + but resolved through gather_data instead of manual asyncio.gather. + """ + from dataclasses import dataclass + from OpenHosta import emulate_async + + @dataclass + class InvoiceSender: + company_name: str + address: str + city: str + postal_code: str + siret_number: str + + async def extract_sender(invoice_text: str) -> InvoiceSender: + """ + Parses the invoice text to find the exact coordinates of the invoice sender. + Does not extract the recipient! + """ + return await emulate_async() + + invoice_1 = "From: ACME Corp Ltd. 12 rue de la Paix, Paris, 75000. SIRET: 123456789. Billed to: John Doe." + invoice_2 = "Facture envoyée le 12 Mars. Expéditeur: BricoPro. 5 impasse des artisans, 69002 Lyon. N° SIRET : 987654321." + + data = [extract_sender(invoice_1), extract_sender(invoice_2)] + + gather_data(data, batch_size=10, max_delay=120) + + for item in data: + assert isinstance(item, InvoiceSender), f"Expected InvoiceSender, got {type(item)}" + assert len(item.company_name) > 0 + assert len(item.city) > 0 From cdda8af8b8edfac3274049e3323c3c200e48b4d3 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 23:07:20 +0200 Subject: [PATCH 09/33] refactor: remove BatchDataContext and expose gather_data utility functions for parallel task execution --- docs/doc.md | 2 +- docs/index.md | 4 +- docs/{examples => }/parallel_processing.md | 6 +- mkdocs.yml | 2 +- src/OpenHosta/__init__.py | 6 +- src/OpenHosta/asynchrone/batchdatacontext.py | 97 -------------------- tests/asynchrone/test_batchdatacontext.py | 94 ------------------- 7 files changed, 13 insertions(+), 198 deletions(-) rename docs/{examples => }/parallel_processing.md (84%) delete mode 100644 src/OpenHosta/asynchrone/batchdatacontext.py delete mode 100644 tests/asynchrone/test_batchdatacontext.py diff --git a/docs/doc.md b/docs/doc.md index 85461990..41ccd47b 100644 --- a/docs/doc.md +++ b/docs/doc.md @@ -37,7 +37,7 @@ Take off your conceptual hat and observe OpenHosta in action through functional - 📚 [**Text Classification:**](examples/text_classification.md) Sorting streams of text directly into rigidly typed `Enum` states. - 🗃️ [**Data Extraction:**](examples/data_extraction.md) Populating massive `Dataclasses` and `Pydantic` modules straight from unstructured text blobs. - 👁️ [**Local OCR with Ollama:**](examples/ocr_local_ollama.md) Passing images using `PIL.Image` directly into `emulate`, performing OCR securely and locally using `glm-ocr`. -- ⚡ [**Parallel Processing:**](examples/parallel_processing.md) Running asynchronous workloads, parsing dataclasses like invoices, and batching prompts. +- ⚡ [**Parallel Processing:**](parallel_processing.md) Running asynchronous workloads, parsing dataclasses like invoices, and batching prompts. - 🌊 [**Streaming & Iteration:**](streaming.md) Receiving results token-by-token or item-by-item to improve UX and handle long responses. --- diff --git a/docs/index.md b/docs/index.md index 414a9b68..dfe043de 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,9 @@ OpenHosta natively supports `int`, `str`, `List`, `Dict`, `Enum`, `dataclass`, ` ### 🛡️ [Safe Context & Error Handling](safe_context_and_uncertainty.md) Handle uncertainty, catch ambiguous LLM responses, and build robust production workflows. +### ⚡ [Parallel Processing](parallel_processing.md) +Simplify concurrent execution of `emulate_async` with the `gather_data` batching utility to parse vast amount of items concurrently. + ### 📐 [Guarded Types](guarded.md) Deep dive into OpenHosta's type validation and conversion system with configurable tolerance. @@ -38,7 +41,6 @@ Deep dive into OpenHosta's type validation and conversion system with configurab | 📚 [Text Classification](examples/text_classification.md) | Classify text into `Enum` states | | 🗃️ [Data Extraction](examples/data_extraction.md) | Populate `dataclass` / `Pydantic` from unstructured text | | 👁️ [Local OCR](examples/ocr_local_ollama.md) | Image processing with `PIL.Image` + Ollama | -| ⚡ [Parallel Processing](examples/parallel_processing.md) | Async batch workloads with `emulate_async` | | 🔄 [Streaming & Iterators](streaming.md) | Stream tokens or yield typed objects with `Iterator` | --- diff --git a/docs/examples/parallel_processing.md b/docs/parallel_processing.md similarity index 84% rename from docs/examples/parallel_processing.md rename to docs/parallel_processing.md index 47b0307f..be0d14c4 100644 --- a/docs/examples/parallel_processing.md +++ b/docs/parallel_processing.md @@ -1,10 +1,10 @@ -# Example: Parallel Processing and Batch Context +# Parallel Processing and Batch Context -When dealing with a vast amount of documents, or extracting information continuously in a backend server context, using `emulate_async` prevents UI blocking and increases thoroughput. +When dealing with a vast amount of documents, or extracting information continuously in a backend server context, OpenHosta provides built-in parallel processing features. Using `emulate_async` prevents UI blocking, and the `gather_data` module simplifies multi-processing workloads without manually tampering with event loops or explicit tasks. ## 1. Extracting Invoices Using Async Tasks -This example demonstrates how to use `emulate_async` alongside `dataclasses` and `asyncio.gather` for highly concurrent validation. We define a data structure for invoice sender coordinates, and extract it instantly from multiple unstructured snippets. +You can use `emulate_async` alongside `dataclasses` and standard `asyncio.gather` for highly concurrent validation. We define a data structure for invoice sender coordinates, and extract it instantly from multiple unstructured snippets. ```python import asyncio diff --git a/mkdocs.yml b/mkdocs.yml index ec385bb2..a9f986f0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,7 +77,7 @@ nav: - Cookbook: - Text Classification: examples/text_classification.md - Data Extraction: examples/data_extraction.md - - Parallel Processing: examples/parallel_processing.md + - Parallel Processing: parallel_processing.md - Local OCR with Ollama: examples/ocr_local_ollama.md - Compatibility: - Python Versions: compatibility_table.md diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index a8d5873f..79903bf1 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -23,10 +23,12 @@ from .pipelines import Pipeline, OneTurnConversationPipeline +from .utils.gather_data import gather_data, gather_data_async + DefaultModel = config.DefaultModel DefaultPipeline = config.DefaultPipeline -all = ( +__all__ = ( "ask", "ask_async", "ask_stream", @@ -36,6 +38,8 @@ "emulate_iterator", "closure", "closure_async", + "gather_data", + "gather_data_async", "SemanticSet", "SemanticDict", "config", diff --git a/src/OpenHosta/asynchrone/batchdatacontext.py b/src/OpenHosta/asynchrone/batchdatacontext.py deleted file mode 100644 index 3286f8ba..00000000 --- a/src/OpenHosta/asynchrone/batchdatacontext.py +++ /dev/null @@ -1,97 +0,0 @@ -import asyncio -import inspect -from typing import Any, Dict, List, Union, Type, Optional - -class Placeholder: - """Marqueur interne pour identifier où injecter le résultat d'une coroutine.""" - def __init__(self, task_index: int): - self.task_index = task_index - -class BatchProxyDict(dict): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._pending_tasks = [] - - def __setitem__(self, key, value): - # Scan récursif pour extraire les coroutines et les remplacer par des Placeholders - processed_value = self._extract_awaitables(value) - super().__setitem__(key, processed_value) - - def _extract_awaitables(self, obj): - """Parcourt l'objet et remplace les coroutines par des Placeholders.""" - if inspect.isawaitable(obj): - idx = len(self._pending_tasks) - self._pending_tasks.append(obj) - return Placeholder(idx) - - elif isinstance(obj, list): - return [self._extract_awaitables(item) for item in obj] - - elif isinstance(obj, tuple): - return tuple(self._extract_awaitables(item) for item in obj) - - elif isinstance(obj, dict): - return {k: self._extract_awaitables(v) for k, v in obj.items()} - - return obj - - async def _resolve(self, batch_size: int, max_delay: Optional[int]): - if not self._pending_tasks: - return - - # 1. Exécution parallèle par batch - all_results = [] - for i in range(0, len(self._pending_tasks), batch_size): - batch = self._pending_tasks[i : i + batch_size] - try: - results = await asyncio.wait_for(asyncio.gather(*batch), timeout=max_delay) - all_results.extend(results) - except asyncio.TimeoutError: - raise TimeoutError(f"BatchDataContext: Délai dépassé ({max_delay}s)") - - # 2. Ré-injection récursive des résultats à la place des placeholders - for key in self.keys(): - self[key] = self._fill_placeholders(self[key], all_results) - - self._pending_tasks.clear() - - def _fill_placeholders(self, obj, results): - """Remplace les objets Placeholder par les résultats réels.""" - if isinstance(obj, Placeholder): - return results[obj.task_index] - elif isinstance(obj, list): - return [self._fill_placeholders(item, results) for item in obj] - elif isinstance(obj, tuple): - return tuple(self._fill_placeholders(item, results) for item in obj) - elif isinstance(obj, dict): - return {k: self._fill_placeholders(v, results) for k, v in obj.items()} - return obj - -class BatchDataContext: - def __init__(self, type: Type = dict, force_batch: bool = True, max_delay: int = 120, batch_size: int = 30): - self.data = BatchProxyDict() - self.batch_size = batch_size - self.max_delay = max_delay - - # --- Support Sync (scripts / jupyter) --- - def __enter__(self): - return self.data - - def __exit__(self, exc_type, exc_val, exc_tb): - if exc_type: return False - try: - loop = asyncio.get_running_loop() - if loop.is_running(): - raise RuntimeError("Utilisez 'async with' dans un environnement asynchrone (FastAPI).") - except RuntimeError as e: - if "environnement asynchrone" in str(e): - raise e - asyncio.run(self.data._resolve(self.batch_size, self.max_delay)) - - # --- Support Async (FastAPI) --- - async def __aenter__(self): - return self.data - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if exc_type: return False - await self.data._resolve(self.batch_size, self.max_delay) \ No newline at end of file diff --git a/tests/asynchrone/test_batchdatacontext.py b/tests/asynchrone/test_batchdatacontext.py deleted file mode 100644 index ad53e3a7..00000000 --- a/tests/asynchrone/test_batchdatacontext.py +++ /dev/null @@ -1,94 +0,0 @@ - -import asyncio -import pytest -from unittest.mock import AsyncMock -from OpenHosta.asynchrone.batchdatacontext import BatchProxyDict, BatchDataContext, Placeholder - -# --- Pour éviter "coroutine was never awaited" --- -async def fake_coro(value): - return value - -# --- Tests pour Placeholder --- -def test_placeholder_creation(): - ph = Placeholder(5) - assert ph.task_index == 5 - -# --- Tests pour BatchProxyDict --- -def test_batchproxydict_setitem_awaitable(): - d = BatchProxyDict() - d["key"] = fake_coro(42) - assert isinstance(d["key"], Placeholder) - assert d["key"].task_index == 0 - assert len(d._pending_tasks) == 1 - -def test_batchproxydict_setitem_nested_list(): - d = BatchProxyDict() - d["key"] = [fake_coro(1), "fixed", fake_coro(2)] - value = d["key"] - assert isinstance(value, list) - assert isinstance(value[0], Placeholder) - assert value[1] == "fixed" - assert isinstance(value[2], Placeholder) - assert len(d._pending_tasks) == 2 - -def test_batchproxydict_setitem_nested_dict(): - d = BatchProxyDict() - d["key"] = {"subkey": fake_coro("value")} - value = d["key"] - assert isinstance(value, dict) - assert isinstance(value["subkey"], Placeholder) - assert len(d._pending_tasks) == 1 - -@pytest.mark.asyncio -async def test_batchproxydict_resolve_simple(): - d = BatchProxyDict() - d["a"] = fake_coro(10) - d["b"] = fake_coro(20) - await d._resolve(batch_size=5, max_delay=None) - assert d["a"] == 10 - assert d["b"] == 20 - -@pytest.mark.asyncio -async def test_batchproxydict_resolve_nested(): - d = BatchProxyDict() - d["data"] = {"users": [fake_coro("Alice"), fake_coro("Bob")]} - await d._resolve(batch_size=5, max_delay=None) - assert d["data"]["users"] == ["Alice", "Bob"] - -@pytest.mark.asyncio -async def test_batchproxydict_resolve_timeout(): - d = BatchProxyDict() - d["stuck"] = asyncio.sleep(10) - with pytest.raises(TimeoutError): - await d._resolve(batch_size=5, max_delay=0.1) - -# --- Test pour BatchDataContext : erreur si 'with' en async --- -@pytest.mark.asyncio -async def test_batchdatacontext_sync_in_async_loop(): - context = BatchDataContext() - with pytest.raises(RuntimeError, match="Utilisez 'async with'"): - with context: - pass # On ne doit jamais arriver ici - -# --- Tests pour BatchDataContext (Sync) --- -def test_batchdatacontext_sync(): - context = BatchDataContext(batch_size=2, max_delay=5) - with context as data: - data["result"] = fake_coro(99) - data["list"] = [fake_coro(1), fake_coro(2)] - - # Après sortie du contexte, les valeurs sont résolues - assert data["result"] == 99 - assert data["list"] == [1, 2] - -# --- Tests pour BatchDataContext (Async) --- -@pytest.mark.asyncio -async def test_batchdatacontext_async(): - context = BatchDataContext(batch_size=2, max_delay=5) - async with context as data: - data["result"] = fake_coro(42) - data["dict"] = {"x": fake_coro(100), "y": "static"} - - assert data["result"] == 42 - assert data["dict"]["x"] == 100 - assert data["dict"]["y"] == "static" \ No newline at end of file From 92e4ad4b23ed77662c7e31ab485fc605fb95fd24 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sun, 19 Apr 2026 23:31:03 +0200 Subject: [PATCH 10/33] refactor: rename emulate_iterator to emulate_variants and improve return type handling --- README.md | 2 +- docs/core_functions.md | 27 ++- docs/index.md | 4 +- docs/parallel_processing.md | 211 ++++++++++++++++++ next/adaptive_semantic_collections.md | 2 +- src/OpenHosta/__init__.py | 4 +- ...mulate_iterator.py => emulate_variants.py} | 9 +- src/OpenHosta/semantics/engine.py | 8 +- src/OpenHosta/semantics/semantic_dict.py | 4 +- src/OpenHosta/semantics/semantic_set.py | 6 +- tests/manual/test_generate.py | 35 +-- 11 files changed, 269 insertions(+), 43 deletions(-) rename src/OpenHosta/exec/{emulate_iterator.py => emulate_variants.py} (98%) diff --git a/README.md b/README.md index 97787a25..0bfec298 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ print(translate("Hello World!", "French")) |---------|-------------| | [`emulate`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | AI-implemented functions from docstrings | | [`emulate_async`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | Non-blocking async variant for concurrency | -| [`emulate_iterator`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | Streaming results via lazy generators | +| [`emulate_variants`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | Streaming results via lazy generators | | [`closure`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | Semantic lambda functions | | [`test`](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/core_functions.md) | Fuzzy logic / semantic boolean tests | | [Types & Pydantic](https://github.com/hand-e-fr/OpenHosta/blob/main/docs/types_and_pydantic.md) | `int`, `dict`, `Enum`, `dataclass`, `Pydantic`, `Callable`… | diff --git a/docs/core_functions.md b/docs/core_functions.md index f5331231..2176918a 100644 --- a/docs/core_functions.md +++ b/docs/core_functions.md @@ -29,20 +29,29 @@ async def capitalize_cities(sentence: str) -> str: print(asyncio.run(capitalize_cities("je suis allé à paris"))) ``` -## `emulate_iterator` -Returns a lazily evaluated generator/iterator. It yields elements one-by-one directly from the underlying LLM streams, vastly reducing latency for list generations. +## `emulate_variants` +Explores the LLM's probability distribution to generate independent, alternative responses (variants) based on token logprobs. It is ideal for exploring uncertainty or generating diverse candidates from a single prompt. -> Note: tested mainly with qwen3:8b-instruct served by ollama +It automatically adapts to the return type annotation: +- If `-> list[T]`: Returns a fully resolved list of variants. +- If `-> Iterator[T]`: Returns a generator that yields variants as they are found. ```python -from OpenHosta import emulate_iterator +from typing import Iterator +from OpenHosta import emulate_variants -def generate_ideas(topic: str) -> list[str]: - """Yield multiple creative ideas based on the topic.""" - return emulate_iterator() +# Returns a list of variants once all are found +def list_variants(topic: str) -> list[str]: + """Suggest three alternative creative names for a project.""" + return emulate_variants(min_probability=1e-2) -for idea in generate_ideas("Open Source Marketing"): - print(idea) # Starts printing before the entire list is fully generated +# Yields variants one by one +def stream_variants(topic: str) -> Iterator[str]: + """Yield multiple alternative titles based on the topic.""" + yield from emulate_variants(min_probability=1e-2) + +for variant in stream_variants("Open Source Marketing"): + print(variant) ``` ## `closure` diff --git a/docs/index.md b/docs/index.md index dfe043de..c9079c46 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,10 +12,10 @@ Welcome to the **OpenHosta** documentation. OpenHosta is the semantic layer for Set up your environment, configure a local or remote model, and run your first `emulate()` call. ### ⚙️ [Core Functions](core_functions.md) -Learn about `emulate`, `emulate_async`, `emulate_iterator`, `closure`, `ask`, and `test`. +Learn about `emulate`, `emulate_async`, `emulate_variants`, `closure`, `ask`, and `test`. ### 🔄 [Streaming & Iterators](streaming.md) -Stream raw tokens with `ask_stream`, or yield structured Python objects one-by-one with `Iterator` return types and `emulate_iterator`. +Stream raw tokens with `ask_stream`, or yield structured Python objects one-by-one with `Iterator` return types and `emulate_variants`. ### 🔧 [Models & Setup](models_and_setup.md) Connect any OpenAI-compatible endpoint (Ollama, vLLM, Azure OpenAI), customize prompts, enable audit mode, and track costs. diff --git a/docs/parallel_processing.md b/docs/parallel_processing.md index be0d14c4..981f4256 100644 --- a/docs/parallel_processing.md +++ b/docs/parallel_processing.md @@ -90,3 +90,214 @@ print("Final Result :", my_data) > Note for Async Environments (FastAPI, Jupyter): If you are running inside an existing event loop, use the asynchronous version: `await gather_data_async(my_data, batch_size=10)`. +--- + +## 3. Advanced Data Pipelines: Stream, Triage, and Gather + +OpenHosta excels when dealing with massive data dumps by combining iterators, sanity checks, and asynchronous parallelism. This powerful architecture generally follows a 4-step pattern: + +1. **Extraction (Iterator)**: Use `emulate` to yield single elements one-by-one from a large, unstructured text blob to avoid waiting for the entire parsing to complete. +2. **Sanity Checks**: Apply a mix of fast, pure Python logic (e.g., regex, keyword search) and robust LLM-based logic (via `emulate_async`) to validate each yielded element. +3. **Triage**: Group the elements into structured objects or separate lists (e.g., `valid_items` vs `rejected_items`). +4. **Gather & Execute**: Use `gather_data` to concurrently execute any pending async LLM calls nested inside your valid data structures, ensuring maximum throughput. + +Here are three real-life implementations of this pattern. + +### Example A: Legal Contract Analysis (Risk Assessment) + +In this scenario, we stream clauses from a massive contract, apply Python sanity checks to filter out short boilerplate text, use an LLM check to see if the clause is legally binding, and finally assess the risk of all valid clauses concurrently. + +```python +import asyncio +from typing import Iterator +from pydantic import BaseModel +from OpenHosta import emulate, emulate_async, gather_data + +class Clause(BaseModel): + title: str + content: str + +class RiskAssessment(BaseModel): + clause_title: str + risk_score: int + reason: str + +def extract_clauses(contract_text: str) -> Iterator[Clause]: + """Yield every distinct liability or obligation clause from the contract.""" + return emulate() + +async def is_legally_binding_async(clause_content: str) -> bool: + """Return True if the clause contains legally binding obligations, False otherwise.""" + return await emulate_async() + +async def score_legal_risk_async(clause_content: str, company_policy: str) -> RiskAssessment: + """Evaluate the legal risk of this clause against the company policy.""" + return await emulate_async() + +async def process_contract(contract_text: str, company_policy: str): + valid_assessments = [] + rejected_clauses = [] + + # 1. Extraction (Streamed) + for clause in extract_clauses(contract_text): + + # 2. Mixed Sanity Checks + # Fast Python Check + if len(clause.content) < 50: + rejected_clauses.append({"clause": clause, "reason": "Too short / Boilerplate"}) + continue + + # LLM Check + is_binding = await is_legally_binding_async(clause.content) + + # 3. Triage + if not is_binding: + rejected_clauses.append({"clause": clause, "reason": "Not legally binding"}) + else: + # Prepare the pending async task for the valid clause + valid_assessments.append(score_legal_risk_async(clause.content, company_policy)) + + # 4. Gather & Execute + # The list contains pending coroutines. We resolve them all in parallel! + gather_data(valid_assessments, batch_size=20) + + return { + "assessments": valid_assessments, + "rejected": rejected_clauses + } + +# Run the pipeline +# result = asyncio.run(process_contract(huge_contract_text, our_policy)) +``` + +### Example B: Customer Support Triage (E-commerce) + +Stream through a messy forum thread of user reviews, drop the 5-star reviews using standard Python logic, let the LLM decide if a complaint is actionable, and then concurrently route and draft apology emails for all actionable complaints. + +```python +import asyncio +from typing import Iterator +from dataclasses import dataclass +from OpenHosta import emulate, emulate_async, gather_data + +@dataclass +class Feedback: + username: str + rating: int # 1 to 5 + text: str + +@dataclass +class TicketDispatch: + username: str + assigned_department: str # Pending async call + draft_response: str # Pending async call + +def extract_feedback(forum_thread: str) -> Iterator[Feedback]: + """Extract individual user reviews from the messy forum thread.""" + return emulate() + +async def is_actionable_complaint_async(feedback_text: str) -> bool: + """Determine if the feedback requires customer support intervention.""" + return await emulate_async() + +async def assign_department_async(feedback_text: str) -> str: + """Assign the ticket to: 'Shipping', 'Billing', 'Technical', or 'General'.""" + return await emulate_async() + +async def draft_apology_async(feedback_text: str) -> str: + """Draft a polite, empathetic apology email addressing the specific issue.""" + return await emulate_async() + +def process_support_thread(forum_thread: str): + dispatched_tickets = [] + ignored_feedback = [] + + # 1. Extraction + for fb in extract_feedback(forum_thread): + + # 2. Python Sanity Check + if fb.rating == 5: + ignored_feedback.append({"feedback": fb, "reason": "Positive Review"}) + continue + + # 3. LLM Check & Triage (Notice we use asyncio.run for the sync environment) + is_actionable = asyncio.run(is_actionable_complaint_async(fb.text)) + + if not is_actionable: + ignored_feedback.append({"feedback": fb, "reason": "Not Actionable / Rant"}) + else: + # We construct our complex data structure with nested pending coroutines + dispatch = TicketDispatch( + username=fb.username, + assigned_department=assign_department_async(fb.text), + draft_response=draft_apology_async(fb.text) + ) + dispatched_tickets.append(dispatch) + + # 4. Gather & Execute + # Automatically traverse the list of TicketDispatch objects and resolve the pending async strings + gather_data(dispatched_tickets, batch_size=15) + + return dispatched_tickets, ignored_feedback +``` + +### Example C: Financial Earnings Call Analysis + +Stream executive statements from a long transcript. Use Python keyword searches to drop irrelevant sentences. Use the LLM to verify if the statement is forward-looking. Finally, concurrently evaluate the predicted stock sentiment for all valid statements. + +```python +import asyncio +from typing import Iterator +from pydantic import BaseModel +from OpenHosta import emulate, emulate_async, gather_data + +class Statement(BaseModel): + executive_name: str + quote: str + +class MarketSentiment(BaseModel): + sentiment: str # "Bullish", "Bearish", "Neutral" + confidence_score: int # 1 to 100 + +def extract_statements(transcript: str) -> Iterator[Statement]: + """Yield all distinct sentences spoken by executives in the transcript.""" + return emulate() + +async def is_forward_looking_async(quote: str) -> bool: + """Check if the quote discusses future expectations, guidance, or projections.""" + return await emulate_async() + +async def predict_stock_sentiment_async(quote: str) -> MarketSentiment: + """Analyze the potential market impact of this forward-looking statement.""" + return await emulate_async() + +async def analyze_earnings_call(transcript: str): + financial_keywords = ["revenue", "growth", "launch", "guidance", "margin", "loss"] + + analyzed_statements = {} + archived_statements = [] + + # 1. Extraction + for stmt in extract_statements(transcript): + + # 2. Python Check: Keyword Filtering + if not any(kw in stmt.quote.lower() for kw in financial_keywords): + archived_statements.append({"quote": stmt.quote, "reason": "No financial keywords"}) + continue + + # 3. LLM Check & Triage + if not await is_forward_looking_async(stmt.quote): + archived_statements.append({"quote": stmt.quote, "reason": "Historical/Factual, not forward-looking"}) + else: + # Store in a dictionary where values are pending coroutines + analyzed_statements[stmt.quote] = predict_stock_sentiment_async(stmt.quote) + + # 4. Gather & Execute + # Resolves all dictionary values in parallel + await gather_data_async(analyzed_statements, batch_size=10) + + return { + "impact_analysis": analyzed_statements, + "archived": archived_statements + } +``` diff --git a/next/adaptive_semantic_collections.md b/next/adaptive_semantic_collections.md index df302230..95ea5573 100644 --- a/next/adaptive_semantic_collections.md +++ b/next/adaptive_semantic_collections.md @@ -3,7 +3,7 @@ ## Contexte Les `SemanticSet` et `SemanticDict` actuels fonctionnent en **monde fermé** : -- Les clusters sont pré-générés à l'init via `emulate_iterator` +- Les clusters sont pré-générés à l'init via `emulate_variants` - Ils sont **fixes** : ajouter/supprimer un élément ne modifie jamais les clusters - Un élément hors du domaine pré-calculé → `ValueError` (outlier) diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index 79903bf1..162144c4 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -13,7 +13,7 @@ from .exec.ask import ask, ask_async, ask_stream, ask_stream_async from .exec.emulate import emulate, emulate_async -from .exec.emulate_iterator import emulate_iterator +from .exec.emulate_variants import emulate_variants from .exec.closure import closure, closure_async # from .semantics import SemanticSet, SemanticDict # Maybe in 5.0 from .semantics.operators import test, test_async @@ -35,7 +35,7 @@ "ask_stream_async", "emulate", "emulate_async", - "emulate_iterator", + "emulate_variants", "closure", "closure_async", "gather_data", diff --git a/src/OpenHosta/exec/emulate_iterator.py b/src/OpenHosta/exec/emulate_variants.py similarity index 98% rename from src/OpenHosta/exec/emulate_iterator.py rename to src/OpenHosta/exec/emulate_variants.py index 1622fd03..c72cf7bf 100644 --- a/src/OpenHosta/exec/emulate_iterator.py +++ b/src/OpenHosta/exec/emulate_variants.py @@ -194,7 +194,7 @@ def _iterator(*args, **kwargs): return _iterator return decorator -def emulate_iterator( +def emulate_variants( pipeline : OneTurnConversationPipeline = config.DefaultPipeline, max_generation = 50, min_probability = 1e-4, @@ -259,4 +259,9 @@ def _iterator(): if counted_generations <= 0: break - return _iterator() + is_generator = inspection.analyse.is_generator + + if is_generator: + return _iterator() + else: + return list(_iterator()) diff --git a/src/OpenHosta/semantics/engine.py b/src/OpenHosta/semantics/engine.py index 4e9b6ea2..54ec7ff5 100644 --- a/src/OpenHosta/semantics/engine.py +++ b/src/OpenHosta/semantics/engine.py @@ -2,7 +2,7 @@ """ Moteur de clustering sémantique. -Génère un nuage d'exemples via emulate_iterator, les embeds, puis les cluster +Génère un nuage d'exemples via emulate_variants, les embeds, puis les cluster avec DBSCAN. Les clusters sont fixes une fois créés. """ @@ -17,7 +17,7 @@ from ..defaults import config from ..core.base_model import Model, ModelCapabilities from ..pipelines import OneTurnConversationPipeline -from ..exec.emulate_iterator import emulate_iterator +from ..exec.emulate_variants import emulate_variants def generate_examples( @@ -28,7 +28,7 @@ def generate_examples( ) -> List[str]: """ Génère des exemples diversifiés pour un axe sémantique donné - en utilisant emulate_iterator (logprob branching). + en utilisant emulate_variants (logprob branching). Args: axis: Description de l'axe sémantique (ex: "Tâches Ménagères") @@ -44,7 +44,7 @@ def generate_examples( def _generate_example() -> str: """placeholder""" - return emulate_iterator( + return emulate_variants( pipeline=pipeline, max_generation=n, min_probability=min_probability diff --git a/src/OpenHosta/semantics/semantic_dict.py b/src/OpenHosta/semantics/semantic_dict.py index c39aed00..9ccf2ea7 100644 --- a/src/OpenHosta/semantics/semantic_dict.py +++ b/src/OpenHosta/semantics/semantic_dict.py @@ -52,9 +52,9 @@ def __init__( axis: Description de l'axe sémantique tolerance: Distance cosine max model: Modèle LLM (défaut: config.DefaultModel) - pipeline: Pipeline pour emulate_iterator + pipeline: Pipeline pour emulate_variants n_examples: Nombre d'exemples à générer - min_probability: Seuil de probabilité pour emulate_iterator + min_probability: Seuil de probabilité pour emulate_variants """ self._key_set = SemanticSet( axis=axis, diff --git a/src/OpenHosta/semantics/semantic_set.py b/src/OpenHosta/semantics/semantic_set.py index 014f3931..70d3ca01 100644 --- a/src/OpenHosta/semantics/semantic_set.py +++ b/src/OpenHosta/semantics/semantic_set.py @@ -2,7 +2,7 @@ """ SemanticSet — Ensemble à clustering sémantique pré-calculé. -Les clusters sont générés à l'initialisation via emulate_iterator, +Les clusters sont générés à l'initialisation via emulate_variants, puis fixés. Les éléments ajoutés sont assignés aux clusters existants. """ @@ -25,7 +25,7 @@ class SemanticSet: Ensemble sémantique avec clustering pré-calculé. À l'initialisation : - 1. Génère un nuage d'exemples via emulate_iterator + 1. Génère un nuage d'exemples via emulate_variants 2. Embed les exemples 3. Cluster via DBSCAN 4. Labellise chaque cluster via le DefaultModel @@ -58,7 +58,7 @@ def __init__( model: Modèle pour embeddings et labelling (défaut: config.DefaultModel) pipeline: Pipeline pour la génération d'exemples (défaut: config.DefaultPipeline) n_examples: Nombre d'exemples à générer - min_probability: Seuil de probabilité pour emulate_iterator + min_probability: Seuil de probabilité pour emulate_variants """ self._axis = axis self._tolerance = tolerance diff --git a/tests/manual/test_generate.py b/tests/manual/test_generate.py index 3cd51eb4..dfe2b510 100644 --- a/tests/manual/test_generate.py +++ b/tests/manual/test_generate.py @@ -10,7 +10,7 @@ from OpenHosta import reload_dotenv reload_dotenv() -from OpenHosta import emulate_iterator +from OpenHosta import emulate_variants # Force logprobs capability for testing from OpenHosta import config @@ -24,18 +24,19 @@ def test_generate_basic(): """ from OpenHosta import print_last_prompt + from typing import Iterator @dataclass class Country: name: str - def random_country_name() -> Country: + def random_country_name() -> Iterator[Country]: """ This function returns the name of a country near France. Returns: dict: The name of a country {"name": str} """ - return emulate_iterator() + return emulate_variants() for p in random_country_name(): @@ -44,23 +45,23 @@ def random_country_name() -> Country: # print_last_prompt(random_country_name) # This returns 19 names with qwen3-vl:8b-instruc (ollama) - def random_country_name() -> Country: + def random_country_name() -> list[Country]: """ This function returns the name of a country. Returns: dict: The name of a country {"name": str} """ - return emulate_iterator(min_probability=1e-2, max_generation=100) + return emulate_variants(min_probability=1e-2, max_generation=100) - def country_that_share_border_with(country: str) -> Country: + def country_that_share_border_with(country: str) -> Iterator[Country]: """ This function returns the name of a country that shares a border with the given country, chosen randomly. Args: dict: The name of a country {"name": str} """ - return emulate_iterator(min_probability=1e-3) + return emulate_variants(min_probability=1e-3) all = set() for p in random_country_name(): @@ -73,18 +74,18 @@ def country_that_share_border_with(country: str) -> Country: len([x for x in all if len(x.split()) < 3]) print([x for x in all if len(x.split()) < 3]) - def nicest_county_in_the_world() -> Country: + def nicest_county_in_the_world() -> list[Country]: """ This function returns the name of the nicest country in the world. Args: dict: The name of a country {"name": str} """ - return emulate_iterator(min_probability=1e-3) + return emulate_variants(min_probability=1e-3) list(nicest_county_in_the_world()) - def letters_of_the_alphabet() -> str: + def letters_of_the_alphabet() -> Iterator[str]: """ This function returns a random letter of the alphabet. It should return only one character. @@ -92,12 +93,12 @@ def letters_of_the_alphabet() -> str: Returns: str: The letter. """ - return emulate_iterator() + yield from emulate_variants() for c in letters_of_the_alphabet(): print(c) - def get_city(country: str) -> str: + def get_city(country: str) -> list[str]: """ This function returns a city of that is in the country. The city is chosen randomly. @@ -105,13 +106,13 @@ def get_city(country: str) -> str: Args: country (str): The name of the country. """ - return emulate_iterator() + return emulate_variants() for country in random_country_name(): for city in get_city(country): print(f"{country:20s}: {city}") - def dis_bonjour_avec_fautes(phrase:str) -> str: + def dis_bonjour_avec_fautes(phrase:str) -> Iterator[str]: """ Reformule la phrase avec les fautes d'orthograph les plus fréquentes. @@ -121,7 +122,7 @@ def dis_bonjour_avec_fautes(phrase:str) -> str: Return: la même phrase avec des fautes d'orthographes. """ - return emulate_iterator(min_probability=0.01) + return emulate_variants(min_probability=0.01) for c in dis_bonjour_avec_fautes("les chats font pas des chiens"): print(c) @@ -131,7 +132,7 @@ class KG_LINK: link:str target:str - def add_grah_link(source) -> KG_LINK: + def add_grah_link(source) -> Iterator[KG_LINK]: """ Suggest linkes in a knowledge graph. @@ -140,7 +141,7 @@ def add_grah_link(source) -> KG_LINK: Return: KG_LINK: The link between the source and the target. """ - return emulate_iterator(min_probability=0.001) + return emulate_variants(min_probability=0.001) for link in add_grah_link("friction"): print(link) \ No newline at end of file From 788833799e666e60bf159327177f02489d715b79 Mon Sep 17 00:00:00 2001 From: Emmanuel BATT Date: Tue, 21 Apr 2026 23:41:03 +0200 Subject: [PATCH 11/33] fix: meta prompt shall not ask for python bloc in python bloc --- src/OpenHosta/core/meta_prompt.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/OpenHosta/core/meta_prompt.py b/src/OpenHosta/core/meta_prompt.py index 3adfd612..f9286f34 100644 --- a/src/OpenHosta/core/meta_prompt.py +++ b/src/OpenHosta/core/meta_prompt.py @@ -134,22 +134,13 @@ def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: Example for 3 items of type str: ```python - "first answer" + ...first answer... ``` ```python - "second answer" + ...second answer... ``` ```python - "third answer" - ``` - {% else %} - OUTPUT FORMAT: - Respond with only the return value, placed inside a single fenced Python code block. - Do not add any prose, explanation, or comments outside the block. - - Example for return type `str`: - ```python - "your answer here" + ...third answer... ``` {% endif %} From cd3053d69011788c351c90e77eaf601e52cd7bba Mon Sep 17 00:00:00 2001 From: Emmanuel BATT Date: Tue, 21 Apr 2026 23:42:54 +0200 Subject: [PATCH 12/33] cicd: version --- pyproject.toml | 2 +- src/OpenHosta/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7f323f91..6cde66ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "OpenHosta" -version = "4.2.0" +version = "4.2.1" description = "A lightweight library integrating LLM natively into Python" keywords = ["AI", "GPT", "Natural language", "Autommatic", "Easy"] authors = [ diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index a8d5873f..4ed71acf 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.2.0" +__version__ = "4.2.1" from .defaults import config from .defaults import reload_dotenv From 42e5a1773247d1ede212724a9466ed291960d7ed Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Wed, 22 Apr 2026 23:38:56 +0200 Subject: [PATCH 13/33] docs: document the Streaming Upgrade and update local test examples to demonstrate Iterator vs. list return types --- docs/core_functions.md | 3 +++ docs/streaming.md | 18 ++++++++++++++++-- tests/manual/local.py | 15 +++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/docs/core_functions.md b/docs/core_functions.md index 2176918a..5c0d200b 100644 --- a/docs/core_functions.md +++ b/docs/core_functions.md @@ -15,6 +15,9 @@ def translate(text: str, language: str) -> str: print(translate("Hello", "French")) ``` +> [!TIP] +> **The Streaming Upgrade:** If you change the return type from `list[T]` to `Iterator[T]`, OpenHosta automatically switches to streaming mode, allowing you to process items as they are generated by the model. + ## `emulate_async` Useful for web applications, heavy IO, or executing multiple LLM calls in parallel without blocking the main event loop. diff --git a/docs/streaming.md b/docs/streaming.md index 64a9e30b..5392c21a 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -95,10 +95,24 @@ for item in extract_action_items(notes): print(f"Task: {item.task} (Priority: {item.priority})") ``` -## Why use Iterators? +### The "Streaming Upgrade" + +Changing a single return type from `list[T]` to `Iterator[T]` is enough to enable streaming. This simple change allows you to start downstream LLM calls or process data before the entire list is fully generated by the model. + +```python +# Standard mode: waits for the full list +def extract_clauses(contract_text: str) -> list[Clause]: + """list every distinct liability or obligation clause from the contract.""" + return emulate() + +# Streaming mode: yields items as they are generated +def extract_clauses(contract_text: str) -> Iterator[Clause]: + """Yield every distinct liability or obligation clause from the contract.""" + return emulate() +``` ### 1. Synchronous Iteration & Network Buffering -Even in a simple synchronous loop, using an iterator provides a significant performance boost. When you use `yield from emulate()`, OpenHosta starts yielding objects as soon as the LLM finishes a block (e.g., one item in a list). +Even in a simple synchronous loop, using an iterator provides a significant performance boost. When you use `return emulate()` (or `yield from emulate()`), OpenHosta starts yielding objects as soon as the LLM finishes a block (e.g., one item in a list). While your code is processing the first item, the LLM is already streaming the next tokens into your **network socket buffer**. You don't "waste" time waiting for the entire response to be downloaded; you process it piece by piece as it arrives. diff --git a/tests/manual/local.py b/tests/manual/local.py index 68716c8a..c1ea9e3d 100644 --- a/tests/manual/local.py +++ b/tests/manual/local.py @@ -17,15 +17,26 @@ class President: start_date: str end_date: str -def list_all(what:str) -> Iterator[dict[str,str]]: +def list_all(what:str) -> list[President]: """ Iterate over all the items of a given type. :param what: The type of items to iterate over. :return: An iterator over the items of the given type. """ return emulate() - + for p in list_all("présidents de la france"): print(p) +def list_all2(what:str) -> Iterator[President]: + """ + Iterate over all the items of a given type. + :param what: The type of items to iterate over. + :return: An iterator over the items of the given type. + """ + return emulate() + +for p in list_all2("présidents de la france"): + print(p) + print_last_prompt(list_all) \ No newline at end of file From a6dafda9d67ba9fc89e0a3d74c4d65059860ba83 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 10:22:53 +0200 Subject: [PATCH 14/33] refactor: improve parsing error handling in primitives and expand guarded collection test coverage --- .../guarded/subclassablecollections.py | 130 ++++++++++-------- tests/guarded/test_collections.py | 21 +++ tests/typing/test_inhabitants.py | 23 ++++ 3 files changed, 116 insertions(+), 58 deletions(-) create mode 100644 tests/typing/test_inhabitants.py diff --git a/src/OpenHosta/guarded/subclassablecollections.py b/src/OpenHosta/guarded/subclassablecollections.py index 531e23b5..546ce260 100644 --- a/src/OpenHosta/guarded/subclassablecollections.py +++ b/src/OpenHosta/guarded/subclassablecollections.py @@ -1,7 +1,62 @@ -from typing import Any, Tuple, Optional +import ast +from typing import Any, Tuple, Optional, List as TypingList from .primitives import GuardedPrimitive, GuardedCallInput, UncertaintyLevel, Tolerance, ProxyWrapper from .type_hints import resolve_struct_hints + +def _split_composite_string(inner: str) -> TypingList[Any]: + """Split a string of comma-separated items that may contain non-literal + expressions (e.g. dataclass constructors, enum reprs) which ast.literal_eval + cannot handle. + + Splits on commas that are NOT inside quotes or nested brackets/parens/braces. + Each resulting part is individually evaluated with ast.literal_eval; if that + fails the raw string is kept, allowing downstream Guarded types to parse it. + + Args: + inner: The content between the outer delimiters (already stripped of + surrounding [], {}, or ()). + + Returns: + A list of parsed items (Python literals where possible, raw strings otherwise). + """ + if not inner: + return [] + + parts: TypingList[str] = [] + depth = 0 + in_quote = False + quote_char = None + last_idx = 0 + + for i, c in enumerate(inner): + if c in '"\'' and (i == 0 or inner[i - 1] != '\\'): + if not in_quote: + in_quote = True + quote_char = c + elif quote_char == c: + in_quote = False + quote_char = None + + if not in_quote: + if c in '({[': + depth += 1 + elif c in ')}]': + depth -= 1 + elif c == ',' and depth == 0: + parts.append(inner[last_idx:i].strip()) + last_idx = i + 1 + + parts.append(inner[last_idx:].strip()) + + evaluated: TypingList[Any] = [] + for part in parts: + try: + evaluated.append(ast.literal_eval(part)) + except (ValueError, SyntaxError): + evaluated.append(part) + return evaluated + class GuardedList(GuardedPrimitive, list): """ Liste sémantique. @@ -54,12 +109,14 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Format "[1, 2, 3]" if value_s.startswith('[') and value_s.endswith(']'): try: - import ast parsed = ast.literal_eval(value_s) if isinstance(parsed, list): items = parsed - except (ValueError, SyntaxError) as e: - pass + except (ValueError, SyntaxError): + try: + items = _split_composite_string(value_s[1:-1].strip()) + except Exception: + pass # Format "1,2,3" (CSV) if items is None and ',' in value_s: @@ -146,21 +203,25 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if value_s.startswith('{') and value_s.endswith('}'): try: - import ast parsed = ast.literal_eval(value_s) if isinstance(parsed, (set, list, tuple)): items = set(parsed) except (ValueError, SyntaxError): - pass + try: + items = set(_split_composite_string(value_s[1:-1].strip())) + except Exception: + pass elif value_s.startswith('[') and value_s.endswith(']'): # Case frozen_set([1, 2]) try: - import ast parsed = ast.literal_eval(value_s) if isinstance(parsed, list): items = set(parsed) - except: - pass + except (ValueError, SyntaxError): + try: + items = set(_split_composite_string(value_s[1:-1].strip())) + except Exception: + pass # Format "1,2,3" (CSV) if items is None and ',' in value_s: @@ -360,65 +421,18 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Format "(1, 2, 3)" if value_s.startswith('(') and value_s.endswith(')'): try: - import ast - # Attempt to parse using ast.literal_eval for safe evaluation parsed = ast.literal_eval(value_s) if isinstance(parsed, tuple): items = parsed else: return UncertaintyLevel(Tolerance.ANYTHING), value, f"Not a tuple: {value}" except (ValueError, SyntaxError): - # Handle cases like (, 're') + # Handle cases like (, 're') # where ast.literal_eval fails due to non-literal expressions try: - # Strip the outer parentheses and split manually by commas not inside nested structures - inner = value_s[1:-1].strip() - if not inner: - items = [] - else: - # Simple split with comma, careful about potential nesting - # This is a less safe fallback, but necessary for non-literal enum representations - parts = [] - depth = 0 - in_quote = False - quote_char = None - last_idx = 0 - for i, c in enumerate(inner): - if c in '"\'' and (i == 0 or inner[i-1] != '\\'): - if not in_quote: - in_quote = True - quote_char = c - elif quote_char == c: - in_quote = False - quote_char = None - - if not in_quote: - if c in '({[': - depth += 1 - elif c in ')}]': - depth -= 1 - elif c == ',' and depth == 0: - parts.append(inner[last_idx:i].strip()) - last_idx = i + 1 - parts.append(inner[last_idx:].strip()) - - # Attempt to evaluate each part individually, fallback to string if needed - evaluated_parts = [] - for part in parts: - try: - # First, try literal_eval for safety - evaluated = ast.literal_eval(part) - except (ValueError, SyntaxError): - # If that fails, keep it as a string representation - # This preserves things like enum instances that can't be literal-evaluated - evaluated = part - evaluated_parts.append(evaluated) - - items = evaluated_parts + items = _split_composite_string(value_s[1:-1].strip()) except Exception: return UncertaintyLevel(Tolerance.ANYTHING), value, "Could not parse string as tuple" - except (ValueError, SyntaxError) as e: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Could not parse tuple from string to {cls._type_py}:\n{e}\n{value}" # Format "1,2,3" (CSV) if items is None and ',' in value_s: diff --git a/tests/guarded/test_collections.py b/tests/guarded/test_collections.py index 1176513a..dedd80f1 100644 --- a/tests/guarded/test_collections.py +++ b/tests/guarded/test_collections.py @@ -261,6 +261,27 @@ def test_nested_structures(self): assert clst == [{"a": 1}, {"b": 2}] assert isinstance(clst[0]["a"], int) + def test_nested_dataclass_string(self): + """Test parsing a list of stringified complex objects (like dataclass).""" + from dataclasses import dataclass + from OpenHosta.guarded.subclassablecollections import guarded_dataclass + + @guarded_dataclass + @dataclass + class Person: + name: str + age: int + + ListType = GuardedList[Person] + # This simulates LLM string output of a list of Person + result = ListType("[Person(name='Alice', age=30), Person(name='Bob', age=25)]") + + assert len(result) == 2 + assert result[0].name == "Alice" + assert result[0].age == 30 + assert result[1].name == "Bob" + assert result[1].age == 25 + def test_mixed_types(self): """Test collections with mixed types.""" lst = GuardedList([1, "two", 3.0, None]) diff --git a/tests/typing/test_inhabitants.py b/tests/typing/test_inhabitants.py new file mode 100644 index 00000000..6e7ecf1f --- /dev/null +++ b/tests/typing/test_inhabitants.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from OpenHosta import emulate + +@dataclass +class Person: + name:str + yearofbirth:int + yearofdeath:int|None + link_type:str + occupation:str + +def list_knwon_inhabitants(country:str, town:str) -> list[Person]: + """ + List the main public personalities associated to the town. + The association can be by birth, death, main working place or residence. + :param country: The country where the town is located + :param town: The town to list the inhabitants of + """ + return emulate() + +if __name__ == '__main__': + res = list_knwon_inhabitants("France", "Saint-Etienne") + print(res) From 928706c90d8bc3d4b1ca7fa5d05836e6db78a20b Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 14:53:22 +0200 Subject: [PATCH 15/33] feat: better error messgages when llm does not respect annotated types --- .agents/rules/use-venv.md | 5 + src/OpenHosta/guarded/primitives.py | 24 ++- src/OpenHosta/guarded/resolver.py | 11 +- .../guarded/subclassablecollections.py | 142 ++++++++++++++++-- tests/guarded/test_collections.py | 23 +++ 5 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 .agents/rules/use-venv.md diff --git a/.agents/rules/use-venv.md b/.agents/rules/use-venv.md new file mode 100644 index 00000000..60845e58 --- /dev/null +++ b/.agents/rules/use-venv.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +--- + +when running a python code you must cd to tests folder and use python from its .venv that was created using uv \ No newline at end of file diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index 07c21743..abdadf79 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -326,7 +326,7 @@ def attempt(cls, value: Any, tolerance: ToleranceLevel|None = None) -> CastingRe if tolerance is None: tolerance = cls._tolerance - full_message = "" + errors = [] # At each layer we have a chance to reduce uncertainty def return_success(cleaned_val, uncertainty, level, message): @@ -369,24 +369,34 @@ def return_success(cleaned_val, uncertainty, level, message): uncertainty, cleaned_native_val, message = cls._parse_native(value) if uncertainty <= tolerance: return return_success(cleaned_native_val, uncertainty, 'native', message) - full_message += f"Native parsing failed: {message}\n" + errors.append(f"Native parsing failed: {message}") uncertainty, cleaned_heuristic_val, message = cls._parse_heuristic(cleaned_native_val) if uncertainty <= tolerance: return return_success(cleaned_heuristic_val, uncertainty, 'heuristic', message) - full_message += f"Heuristic parsing failed: {message}\n" + errors.append(f"Heuristic parsing failed: {message}") uncertainty, cleaned_semantic_value, message = cls._parse_semantic(cleaned_heuristic_val) if uncertainty <= tolerance: return return_success(cleaned_semantic_value, uncertainty, 'semantic', message) - full_message += f"Semantic parsing failed: {message}\n" + errors.append(f"Semantic parsing failed: {message}") uncertainty, cleaned_knowledge_value, message = cls._parse_knowledge(cleaned_semantic_value) if uncertainty <= tolerance: return return_success(cleaned_knowledge_value, uncertainty, 'knowledge', message) - full_message += f"Knowledge parsing failed: {message}\n" - - return CastingResult(False, None, None, Tolerance.ANYTHING, 'failed', value, cls._type_py, full_message) + errors.append(f"Knowledge parsing failed: {message}") + + def _get_best_error(errors_list): + for err in errors_list: + if err and "→" in err: + # On retire le préfixe "Native parsing failed: " ou "Heuristic..." s'il existe + if ":" in err and ("parsing failed" in err): + return err.split(":", 1)[1].strip() + return err + return "\n".join(e for e in errors_list if e) + + best_message = _get_best_error(errors) + return CastingResult(False, None, None, Tolerance.ANYTHING, 'failed', value, cls._type_py, best_message) # --- Hooks Abstraits (À implémenter par SemanticInt, SemanticUtf8...) --- diff --git a/src/OpenHosta/guarded/resolver.py b/src/OpenHosta/guarded/resolver.py index ad87cea4..9058c42c 100644 --- a/src/OpenHosta/guarded/resolver.py +++ b/src/OpenHosta/guarded/resolver.py @@ -77,7 +77,16 @@ def type_returned_data(response: Any, expected_type: type|None) -> Any: # Prefer guarded data if available, pull_type_data_section will unwrap if needed return res.guarded_data if res.guarded_data is not None else res.data - raise ValueError(f"Failed to convert response to {expected_type}: {res.error_message}") + error_msg = f"Impossible de convertir la réponse du LLM vers le type {expected_type}.\n\n" + error_msg += f"=== Réponse du LLM ===\n{response}\n======================\n\n" + error_msg += f"=== Détail de l'erreur ===\n" + + if res.error_message and "→" in res.error_message: + error_msg += res.error_message + else: + error_msg += f"Type global invalide ou non parsable.\nRaison: {res.error_message}" + + raise ValueError(error_msg) class TypeResolver: diff --git a/src/OpenHosta/guarded/subclassablecollections.py b/src/OpenHosta/guarded/subclassablecollections.py index 546ce260..61267281 100644 --- a/src/OpenHosta/guarded/subclassablecollections.py +++ b/src/OpenHosta/guarded/subclassablecollections.py @@ -57,6 +57,44 @@ def _split_composite_string(inner: str) -> TypingList[Any]: evaluated.append(part) return evaluated +def _split_dict_item(item: str) -> Tuple[Any, Any]: + """Safely split a single key-value string (e.g. '"a": Person(...)') by its colon.""" + depth = 0 + in_quote = False + quote_char = None + + for i, c in enumerate(item): + if c in '"\'' and (i == 0 or item[i - 1] != '\\'): + if not in_quote: + in_quote = True + quote_char = c + elif quote_char == c: + in_quote = False + quote_char = None + + if not in_quote: + if c in '({[': + depth += 1 + elif c in ')}]': + depth -= 1 + elif c == ':' and depth == 0: + k_str = item[:i].strip() + v_str = item[i+1:].strip() + + try: + k_eval = ast.literal_eval(k_str) + except (ValueError, SyntaxError): + k_eval = k_str + + try: + v_eval = ast.literal_eval(v_str) + except (ValueError, SyntaxError): + v_eval = v_str + + return k_eval, v_eval + + raise ValueError(f"No valid colon found in dict item: {item}") + class GuardedList(GuardedPrimitive, list): """ Liste sémantique. @@ -80,7 +118,7 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if cls._item_type: try: converted = [] - for item in value: + for i, item in enumerate(value): r = cls._item_type.attempt(item) # We MUST use r.data to get the native value. # If attempt fails, it will raise eventually or we fallback to the raw item if we are tolerant. @@ -88,7 +126,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if r.success: converted.append(r.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {r.error_message}" + inner_err = r.error_message or "" + if "→" in inner_err: + msg = f"À l'index {i}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément à l'index {i}: Type invalide. Reçu {repr(item)} (type: {type(item).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg return UncertaintyLevel(Tolerance.STRICT), converted, None except Exception as e: return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {e}" @@ -133,12 +176,17 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if cls._item_type: try: converted = [] - for item in items: + for i, item in enumerate(items): r = cls._item_type.attempt(item) if r.success: converted.append(r.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item validation failed: {r.error_message}" + inner_err = r.error_message or "" + if "→" in inner_err: + msg = f"À l'index {i}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément à l'index {i}: Type invalide. Reçu {repr(item)} (type: {type(item).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg return UncertaintyLevel(Tolerance.PRECISE), converted, None except Exception as e: return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {e}" @@ -179,7 +227,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if r.success: converted.add(r.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {r.error_message}" + inner_err = r.error_message or "" + if "→" in inner_err: + msg = f"Dans l'élément {repr(item)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément dans le Set: Type invalide. Reçu {repr(item)} (type: {type(item).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg return UncertaintyLevel(Tolerance.STRICT), converted, None except Exception as e: return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {e}" @@ -243,7 +296,12 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if res.success: converted_items.append(res.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item validation failed: {res.error_message}" + inner_err = res.error_message or "" + if "→" in inner_err: + msg = f"Dans l'élément {repr(item)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément dans le Set: Type invalide. Reçu {repr(item)} (type: {type(item).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg return UncertaintyLevel(Tolerance.PRECISE), set(converted_items), None except Exception as e: return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item conversion failed: {e}" @@ -284,7 +342,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if cls._key_type: rk = cls._key_type.attempt(k) if not rk.success: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Key conversion failed: {rk.error_message}" + inner_err = rk.error_message or "" + if "→" in inner_err: + msg = f"Dans la clé {repr(k)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Clé {repr(k)}: Type invalide. Reçu {repr(k)} (type: {type(k).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg new_k = rk.data else: new_k = k @@ -292,7 +355,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if cls._value_type: rv = cls._value_type.attempt(v) if not rv.success: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Value conversion failed: {rv.error_message}" + inner_err = rv.error_message or "" + if "→" in inner_err: + msg = f"Dans la valeur de la clé {repr(k)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Valeur pour la clé {repr(k)}: Type invalide. Reçu {repr(v)} (type: {type(v).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg new_v = rv.data else: new_v = v @@ -320,12 +388,25 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s except (json.JSONDecodeError, ValueError): # Essayer avec ast.literal_eval try: - import ast parsed = ast.literal_eval(value_s) if isinstance(parsed, dict): items = parsed except (ValueError, SyntaxError): - pass + try: + inner = value_s[1:-1].strip() + if not inner: + items = {} + else: + parts = _split_composite_string(inner) + new_items = {} + for part in parts: + # _split_composite_string might return the raw string if literal_eval fails + if isinstance(part, str): + k, v = _split_dict_item(part) + new_items[k] = v + items = new_items + except Exception: + pass # Tenter de convertir en dict if items is None: @@ -342,7 +423,12 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if cls._key_type: rk = cls._key_type.attempt(k) if not rk.success: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Key validation failed: {rk.error_message}" + inner_err = rk.error_message or "" + if "→" in inner_err: + msg = f"Dans la clé {repr(k)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Clé {repr(k)}: Type invalide. Reçu {repr(k)} (type: {type(k).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg new_k = rk.data else: new_k = k @@ -350,7 +436,12 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if cls._value_type: rv = cls._value_type.attempt(v) if not rv.success: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Value validation failed: {rv.error_message}" + inner_err = rv.error_message or "" + if "→" in inner_err: + msg = f"Dans la valeur de la clé {repr(k)}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Valeur pour la clé {repr(k)}: Type invalide. Reçu {repr(v)} (type: {type(v).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg new_v = rv.data else: new_v = v @@ -399,7 +490,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] if res.success: converted_items.append(res.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item {i} conversion failed: {res.error_message}" + inner_err = res.error_message or "" + if "→" in inner_err: + msg = f"À l'index {i}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément à l'index {i}: Type invalide. Reçu {repr(value[i])} (type: {type(value[i]).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg converted = tuple(converted_items) return UncertaintyLevel(Tolerance.STRICT), converted, None except Exception as e: @@ -457,7 +553,12 @@ def _content_validation(cls, items, value) -> Tuple[UncertaintyLevel, Any, str | if item_result.success: converted_items.append(item_result.data) else: - return UncertaintyLevel(Tolerance.ANYTHING), value, f"Item {i} validation failed: {item_result.error_message}" + inner_err = item_result.error_message or "" + if "→" in inner_err: + msg = f"À l'index {i}:\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = f"→ Élément à l'index {i}: Type invalide. Reçu {repr(items[i])} (type: {type(items[i]).__name__})." + return UncertaintyLevel(Tolerance.ANYTHING), value, msg converted = tuple(converted_items) return UncertaintyLevel(Tolerance.PRECISE), converted, None except Exception as e: @@ -565,7 +666,18 @@ def _coerce_dataclass_inputs(cls, args_tuple, kwargs_dict): attempt_result = guarded_type.attempt(raw_value) if not attempt_result.success: - raise ValueError(attempt_result.error_message) + inner_err = attempt_result.error_message or "" + if "→" in inner_err: + msg = f"Dans le champ '{field.name}':\n {inner_err.replace(chr(10), chr(10) + ' ')}" + else: + msg = ( + f"→ Champ '{field.name}': Type invalide.\n" + f" - Reçu : {repr(raw_value)} (type: {type(raw_value).__name__})\n" + f" - Attendu : {expected_field_type}" + ) + if raw_value is None: + msg += f"\n - Solution: Si 'None' est acceptable, modifiez le type en `{expected_field_type} | None` ou `Optional[{expected_field_type}]`." + raise ValueError(msg) # We use guarded_data for internal storage to preserve metadata and pass core tests converted_kwargs[field.name] = attempt_result.guarded_data diff --git a/tests/guarded/test_collections.py b/tests/guarded/test_collections.py index dedd80f1..9928a83c 100644 --- a/tests/guarded/test_collections.py +++ b/tests/guarded/test_collections.py @@ -282,6 +282,29 @@ class Person: assert result[1].name == "Bob" assert result[1].age == 25 + def test_nested_dict_dataclass_string(self): + """Test parsing a dict of stringified complex objects (like dataclass).""" + from dataclasses import dataclass + from OpenHosta.guarded.subclassablecollections import guarded_dataclass + + @guarded_dataclass + @dataclass + class Person: + name: str + age: int + + DictType = GuardedDict[GuardedUtf8, Person] + # This simulates LLM string output of a dict of Person + result = DictType("{'alice': Person(name='Alice', age=30), 'bob': Person(name='Bob', age=25)}") + + assert len(result) == 2 + assert "alice" in result + assert result["alice"].name == "Alice" + assert result["alice"].age == 30 + assert "bob" in result + assert result["bob"].name == "Bob" + assert result["bob"].age == 25 + def test_mixed_types(self): """Test collections with mixed types.""" lst = GuardedList([1, "two", 3.0, None]) From 7bca195e71148c89ecb99243d26351509f6b5285 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 15:03:52 +0200 Subject: [PATCH 16/33] doc: explain how to disaable thinking with ollama --- docs/getting_started.md | 3 ++ docs/models_and_setup.md | 14 +++++++++ next/classifier.py | 59 +++++++++++++++++++++++++++++++++++++ next/structured_thinking.py | 32 ++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 next/classifier.py create mode 100644 next/structured_thinking.py diff --git a/docs/getting_started.md b/docs/getting_started.md index a39f3cd3..3a2e0011 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -18,6 +18,9 @@ from OpenHosta import config config.DefaultModel.base_url = "http://localhost:11434/v1" config.DefaultModel.model_name = "qwen3.5:4b" config.DefaultModel.api_key = "not used by ollama local api" + +# Tip: Disable reasoning/thinking to speed up Qwen +config.DefaultModel.api_parameters |= {"reasoning": {"effort": "none"}} ``` ## Supported Environment Variables diff --git a/docs/models_and_setup.md b/docs/models_and_setup.md index 9ee8171e..6a747d38 100644 --- a/docs/models_and_setup.md +++ b/docs/models_and_setup.md @@ -45,6 +45,20 @@ vllm_model = OpenAICompatibleModel( ) ``` +## Ollama & Local Models + +When using [Ollama](https://ollama.com/), you can configure advanced parameters via `api_parameters`. + +### Disabling Reasoning (e.g., Qwen) +Some models like **Qwen** include reasoning capabilities (thinking) by default. If you want to disable this to speed up responses or get direct output, you can set the `reasoning` effort to `none`: + +```python +from OpenHosta import config + +# Disable thinking for Qwen models in Ollama +config.DefaultModel.api_parameters |= {"reasoning": {"effort": "none"}} +``` + ## Changing the MetaPrompt You can customize the prompt templates via `config.DefaultPipeline.user_call_meta_prompt` or create your own `MetaPrompt`. diff --git a/next/classifier.py b/next/classifier.py new file mode 100644 index 00000000..162e45f4 --- /dev/null +++ b/next/classifier.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass +from typing import Literal + +# --- Définition des axes de classification --- + +# L'objectif principal de l'idée +DomaineType = Literal[ + "Lutte anti-nuisibles", + "Gestion de l'eau et du sol", + "Aménagement de l'espace", + "Stratégie de culture", + "Biodiversité et synergies" +] + +# Le moyen d'action utilisé pour arriver à cet objectif +MethodeType = Literal[ + "Protection physique", + "Association végétale", + "Organisation temporelle", + "Infrastructure matérielle", + "Pratique d'entretien" +] + +@dataclass(frozen=True) +class CleClassification: + """ + Clé permettant de regrouper les idées de jardinage. + Le paramètre frozen=True permet d'utiliser cette dataclass comme clé dans un dictionnaire. + """ + domaine: DomaineType + methode: MethodeType + +from OpenHosta import emulate, emulate_async + +async def classifier(idee: str) -> CleClassification: + """ + Classifie une idée de jardinage en fonction de son domaine et de sa méthode. + """ + return await emulate_async() + +from OpenHosta import gather_data + +with open("../next/liste_idees_a_selectionner.md", 'r') as file: + text = file.read() + +data = [classifier(x.strip()) for x in text.split("---")[:2] if x.strip()] + +from OpenHosta import print_last_prompt, print_last_decoding, reload_dotenv +reload_dotenv() +#gather_data(data[:1]) +gather_data(data) + +gather_data(data, max_delay=1200) +for k,v in data.items(): + print(k,v.val()) + +print_last_prompt(classifier) +print_last_decoding(classifier) + diff --git a/next/structured_thinking.py b/next/structured_thinking.py new file mode 100644 index 00000000..352c38e0 --- /dev/null +++ b/next/structured_thinking.py @@ -0,0 +1,32 @@ + +from dataclasses import dataclass +from OpenHosta import emulate_async +from OpenHosta import config + +config.DefaultModel.model_name="qwen3.5:4b" +config.DefaultModel.base_url="http://localhost:11434" +config.DefaultModel.api_parameters |= {"reasoning": {"effort": "none"}} + +@dataclass +class Step: + id:int + description:str + parent_id:int|None + +@dataclass +class Answer: + hypothesis_with_uncertainty:list[tuple[str,float]] + rational_steps:list[Step] + conclusion:str + +async def answer(question:str) -> Answer: + """ + Answer a question using a well structured thinking + In the hypothesis_with_uncertainty, import only hypothesis with uncertainty above 5%. All hypothesis used in the rational_steps must be included in the hypothesis_with_uncertainty. + In rational_steps, having only one step is acceptable if there is only one hypothesis and answer is obviouse given that hypothesis. + """ + return await emulate_async() + +import asyncio +result = asyncio.run(answer("Quelle est la capitale de la France ?")) +print(result) \ No newline at end of file From c8a9934023747132dfff5770bc5c7bee711d78e9 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 16:07:36 +0200 Subject: [PATCH 17/33] feat: add assistant_starts_with as a force_llm_args key --- .agents/rules/use-venv.md | 6 +- src/OpenHosta/models/OpenAICompatible.py | 28 ++- src/OpenHosta/pipelines/simple_pipeline.py | 269 ++++++++++++--------- tests/manual/force_assistant_message.py | 28 +++ 4 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 tests/manual/force_assistant_message.py diff --git a/.agents/rules/use-venv.md b/.agents/rules/use-venv.md index 60845e58..23c46f2d 100644 --- a/.agents/rules/use-venv.md +++ b/.agents/rules/use-venv.md @@ -2,4 +2,8 @@ trigger: always_on --- -when running a python code you must cd to tests folder and use python from its .venv that was created using uv \ No newline at end of file +I have created a .venv in OpenHosta.git/tests using uv +I then chnaged directory to tests/ and I installed the local clone of OpenHosta using uv pip install -e .. +I write default LLM credentials in tests/.env so that OpenHosta reads it when I start the python interpreter from tests/ folder + +When running a python code you must cd to tests folder and use python from its .venv that was created using uv \ No newline at end of file diff --git a/src/OpenHosta/models/OpenAICompatible.py b/src/OpenHosta/models/OpenAICompatible.py index 325443e5..5792c0e8 100644 --- a/src/OpenHosta/models/OpenAICompatible.py +++ b/src/OpenHosta/models/OpenAICompatible.py @@ -378,14 +378,24 @@ def print_last_prompt(self, inspection): """ Print the last prompt sent to the LLM when using function `function_pointer`. """ - if "llm_api_messages_sent" in inspection.logs and \ - len(inspection.logs["llm_api_messages_sent"]) >= 1: - print("\nSystem prompt:\n-----------------") - print(inspection.logs["llm_api_messages_sent"][0]["content"][0]["text"]) - if "llm_api_messages_sent" in inspection.logs and \ - len(inspection.logs["llm_api_messages_sent"]) >= 2: - print("\nUser prompt:\n-----------------") - print(inspection.logs["llm_api_messages_sent"][1]["content"][0]["text"]) + if "llm_api_messages_sent" in inspection.logs: + for i, msg in enumerate(inspection.logs["llm_api_messages_sent"]): + role = msg.get("role", "unknown") + content = msg.get("content", []) + + text = "" + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text += part.get("text", "") + elif isinstance(part, str): + text += part + elif isinstance(content, str): + text = content + + print(f"\n{role.capitalize()} prompt:\n-----------------") + print(text) + if "rational" in inspection.logs and inspection.logs["rational"]: print("\nRational:\n-----------------") print(inspection.logs["rational"]) @@ -393,6 +403,6 @@ def print_last_prompt(self, inspection): "choices" in inspection.logs["llm_api_response"] and \ len(inspection.logs["llm_api_response"]["choices"]) >= 1: print("\nLLM response:\n-----------------") - print(inspection.logs["llm_api_response"]["choices"][0]["message"]["content"]) + print(inspection.logs["llm_api_response"]["choices"][0]["message"].get("content", "")) # --- Removed old embedding_api_call as it's now handled by base class aliasing to _embed_without_retry --- diff --git a/src/OpenHosta/pipelines/simple_pipeline.py b/src/OpenHosta/pipelines/simple_pipeline.py index 7f779289..9c55dc49 100644 --- a/src/OpenHosta/pipelines/simple_pipeline.py +++ b/src/OpenHosta/pipelines/simple_pipeline.py @@ -341,6 +341,11 @@ def pull_extract_messages(self, inspection:Inspection, response_dict:dict) -> di raw_response = inspection.model.get_response_content(response_dict) + # Prepend assistant_starts_with if it was used + assistant_starts_with = inspection.force_llm_args.get("assistant_starts_with") + if assistant_starts_with: + raw_response = assistant_starts_with + raw_response + return raw_response @staticmethod @@ -429,6 +434,15 @@ def push_build_messages(self, inspection:Inspection, meta_messages:MetaDialog, e "role": role, "content": message_content }] + + # Handle assistant_starts_with + assistant_starts_with = inspection.force_llm_args.get("assistant_starts_with") + if assistant_starts_with: + if messages and messages[-1]["role"] == "assistant": + messages[-1]["content"].append({"type": "text", "text": assistant_starts_with}) + else: + messages.append({"role": "assistant", "content": [{"type": "text", "text": assistant_starts_with}]}) + return messages def push(self, inspection:Inspection) -> dict: @@ -498,49 +512,57 @@ def execute(self, inspection: Inspection, force_llm_args: dict, is_async: bool = max_retries = config.MAX_RETRIES last_exception = None - for attempt in range(max_retries): - start_time = time.time() - try: - # 1. Push - messages = self.push(inspection) - - # 2. Call LLM - llm_args = inspection.force_llm_args | force_llm_args - if is_async: - # In python 3.8+ asyncio.get_event_loop() is discouraged outside main thread, but run_until_complete is not used here, we assume it's awaited in emulate_async - # But wait, execute cannot be async if it has the same signature. - # We will handle async separately in execute_async - raise NotImplementedError("Use execute_async for asynchronous execution.") - else: - response_dict = inspection.model.api_call(messages, llm_args) - - # 3. Pull - response_data = self.pull(inspection, response_dict) - - duration = time.time() - start_time - trigger_audit_event("emulate_success", { - "function": inspection.analyse.name, - "attempt": attempt + 1, - "duration": duration, - "model": inspection.model.model_name - }) - - return response_data - - except (ValueError, TypeError, UncertaintyError) as e: - duration = time.time() - start_time - last_exception = e - - trigger_audit_event("emulate_retry", { - "function": inspection.analyse.name, - "attempt": attempt + 1, - "error": str(e), - "duration": duration - }) - - # Optionally, if we wanted to feedback the error to the LLM, we could add a user message here. - # For now, simply retrying allows the model's stochastic nature to generate a new answer. - continue + # Save old args and merge one-shot args for this execution + old_force_llm_args = inspection.force_llm_args + inspection.force_llm_args = inspection.force_llm_args | force_llm_args + + try: + for attempt in range(max_retries): + start_time = time.time() + try: + # 1. Push + messages = self.push(inspection) + + # 2. Call LLM + # llm_args already contains both now via inspection.force_llm_args + llm_args = inspection.force_llm_args + if is_async: + # In python 3.8+ asyncio.get_event_loop() is discouraged outside main thread, but run_until_complete is not used here, we assume it's awaited in emulate_async + # But wait, execute cannot be async if it has the same signature. + # We will handle async separately in execute_async + raise NotImplementedError("Use execute_async for asynchronous execution.") + else: + response_dict = inspection.model.api_call(messages, llm_args) + + # 3. Pull + response_data = self.pull(inspection, response_dict) + + duration = time.time() - start_time + trigger_audit_event("emulate_success", { + "function": inspection.analyse.name, + "attempt": attempt + 1, + "duration": duration, + "model": inspection.model.model_name + }) + + return response_data + + except (ValueError, TypeError, UncertaintyError) as e: + duration = time.time() - start_time + last_exception = e + + trigger_audit_event("emulate_retry", { + "function": inspection.analyse.name, + "attempt": attempt + 1, + "error": str(e), + "duration": duration + }) + + # Optionally, if we wanted to feedback the error to the LLM, we could add a user message here. + # For now, simply retrying allows the model's stochastic nature to generate a new answer. + continue + finally: + inspection.force_llm_args = old_force_llm_args trigger_audit_event("emulate_failure", { "function": inspection.analyse.name, @@ -556,40 +578,47 @@ async def execute_async(self, inspection: Inspection, force_llm_args: dict) -> A max_retries = config.MAX_RETRIES last_exception = None - for attempt in range(max_retries): - start_time = time.time() - try: - # 1. Push - messages = self.push(inspection) - - # 2. Call LLM - llm_args = inspection.force_llm_args | force_llm_args - response_dict = await inspection.model.api_call_async(messages, llm_args) - - # 3. Pull - response_data = self.pull(inspection, response_dict) - - duration = time.time() - start_time - trigger_audit_event("emulate_async_success", { - "function": inspection.analyse.name, - "attempt": attempt + 1, - "duration": duration, - "model": inspection.model.model_name - }) - - return response_data - - except (ValueError, TypeError, UncertaintyError) as e: - duration = time.time() - start_time - last_exception = e - - trigger_audit_event("emulate_async_retry", { - "function": inspection.analyse.name, - "attempt": attempt + 1, - "error": str(e), - "duration": duration - }) - continue + # Save old args and merge one-shot args for this execution + old_force_llm_args = inspection.force_llm_args + inspection.force_llm_args = inspection.force_llm_args | force_llm_args + + try: + for attempt in range(max_retries): + start_time = time.time() + try: + # 1. Push + messages = self.push(inspection) + + # 2. Call LLM + llm_args = inspection.force_llm_args + response_dict = await inspection.model.api_call_async(messages, llm_args) + + # 3. Pull + response_data = self.pull(inspection, response_dict) + + duration = time.time() - start_time + trigger_audit_event("emulate_async_success", { + "function": inspection.analyse.name, + "attempt": attempt + 1, + "duration": duration, + "model": inspection.model.model_name + }) + + return response_data + + except (ValueError, TypeError, UncertaintyError) as e: + duration = time.time() - start_time + last_exception = e + + trigger_audit_event("emulate_async_retry", { + "function": inspection.analyse.name, + "attempt": attempt + 1, + "error": str(e), + "duration": duration + }) + continue + finally: + inspection.force_llm_args = old_force_llm_args trigger_audit_event("emulate_async_failure", { "function": inspection.analyse.name, @@ -606,23 +635,33 @@ def execute_stream(self, inspection: Inspection, force_llm_args: dict, item_type inspection.logs["response_string"] = "" inspection.logs["response_data"] = [] - messages = self.push_streaming(inspection, item_type) - llm_args = inspection.force_llm_args | force_llm_args - - buffer = "" - for chunk in inspection.model.generate_stream(messages, **llm_args): - inspection.logs["answer"] += chunk - buffer += chunk - # Extract as many complete blocks as possible from the buffer - while True: - content, remainder = self._extract_next_code_block(buffer) - if content is None: - break - buffer = remainder - inspection.logs["response_string"] += f"```python\n{content}\n```\n" - typed_item = self._pull_single_item(inspection, content, item_type) - if typed_item is not None: - yield typed_item + # Save old args and merge one-shot args for this execution + old_force_llm_args = inspection.force_llm_args + inspection.force_llm_args = inspection.force_llm_args | force_llm_args + + try: + messages = self.push_streaming(inspection, item_type) + llm_args = inspection.force_llm_args + + assistant_starts_with = llm_args.get("assistant_starts_with", "") + buffer = assistant_starts_with + inspection.logs["answer"] = assistant_starts_with + + for chunk in inspection.model.generate_stream(messages, **llm_args): + inspection.logs["answer"] += chunk + buffer += chunk + # Extract as many complete blocks as possible from the buffer + while True: + content, remainder = self._extract_next_code_block(buffer) + if content is None: + break + buffer = remainder + inspection.logs["response_string"] += f"```python\n{content}\n```\n" + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + finally: + inspection.force_llm_args = old_force_llm_args # Flush any remaining partial block at end-of-stream if buffer.strip(): @@ -643,22 +682,32 @@ async def execute_stream_async(self, inspection: Inspection, force_llm_args: dic inspection.logs["response_string"] = "" inspection.logs["response_data"] = [] - messages = self.push_streaming(inspection, item_type) - llm_args = inspection.force_llm_args | force_llm_args - - buffer = "" - async for chunk in inspection.model.generate_stream_async(messages, **llm_args): - inspection.logs["answer"] += chunk - buffer += chunk - while True: - content, remainder = self._extract_next_code_block(buffer) - if content is None: - break - buffer = remainder - inspection.logs["response_string"] += f"```python\n{content}\n```\n" - typed_item = self._pull_single_item(inspection, content, item_type) - if typed_item is not None: - yield typed_item + # Save old args and merge one-shot args for this execution + old_force_llm_args = inspection.force_llm_args + inspection.force_llm_args = inspection.force_llm_args | force_llm_args + + try: + messages = self.push_streaming(inspection, item_type) + llm_args = inspection.force_llm_args + + assistant_starts_with = llm_args.get("assistant_starts_with", "") + buffer = assistant_starts_with + inspection.logs["answer"] = assistant_starts_with + + async for chunk in inspection.model.generate_stream_async(messages, **llm_args): + inspection.logs["answer"] += chunk + buffer += chunk + while True: + content, remainder = self._extract_next_code_block(buffer) + if content is None: + break + buffer = remainder + inspection.logs["response_string"] += f"```python\n{content}\n```\n" + typed_item = self._pull_single_item(inspection, content, item_type) + if typed_item is not None: + yield typed_item + finally: + inspection.force_llm_args = old_force_llm_args if buffer.strip(): probe = buffer if buffer.rstrip().endswith("```") else buffer + "\n```" diff --git a/tests/manual/force_assistant_message.py b/tests/manual/force_assistant_message.py new file mode 100644 index 00000000..ba0a03cc --- /dev/null +++ b/tests/manual/force_assistant_message.py @@ -0,0 +1,28 @@ +from OpenHosta import emulate, config + +config.DefaultModel.model_name="qwen3.5:4b" +config.DefaultModel.base_url="http://127.0.0.1:11434" +config.DefaultModel.api_parameters |= {"reasoning": {"effort": "none"}} + +def answer_as_repl(request:str) -> str: + """ + Answer the request as if you were in a REPL. + """ + return emulate(force_llm_args={"assistant_starts_with":">>> "}) + + +answer_as_repl("print('hello')") + +from OpenHosta import print_last_prompt, print_last_decoding +print_last_prompt(answer_as_repl) +print_last_decoding(answer_as_repl) + +def fill_dict_with(content:str)->dict: + """ + Return the list of capitals as a dict[Country: Capital] with country and Capital as string. + """ + return emulate(force_llm_args={"assistant_starts_with":'```python\n{'}) + +print(fill_dict_with("list of european capital cities")) +print_last_prompt(fill_dict_with) + From 049f5a7c7dc01f9ccfd23e4a462b126e7eda21fa Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 17:13:25 +0200 Subject: [PATCH 18/33] =?UTF-8?q?refacto:=20M=C3=A9ta-Prompts=20optimis?= =?UTF-8?q?=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unification des types dans un seul bloc Python, suppression des types redondants (Literal) et meilleure mise en forme des commentaires pour une lecture plus fluide par le LLM. --- src/OpenHosta/core/analizer.py | 49 ++++++++++++++++--- src/OpenHosta/guarded/resolver.py | 4 ++ .../guarded/subclassablecollections.py | 12 ++--- 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index 2c9240d9..8555466d 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -78,8 +78,27 @@ def describe_type_as_python(p_type) -> str: try: guarded_type = TypeResolver.resolve(p_type) + type_name = nice_type_name(p_type) + + type_py_repr = getattr(guarded_type, "_type_py_repr", NotImplemented) + if type_py_repr is not NotImplemented: + type_py_repr_str = str(type_py_repr) + else: + type_py_repr_str = str(getattr(guarded_type, "_type_py", "")) + + type_en = getattr(guarded_type, "_type_en", "") + + doc_lines = [] + doc_lines.append(f"# Type: {type_name}") + if type_en: + doc_lines.append(f"# Description: {type_en}") + + if "\n" in type_py_repr_str: + doc_lines.append(type_py_repr_str) + else: + doc_lines.append(f"# Python Base: {type_py_repr_str}") - return str(guarded_type) + return "\n".join(doc_lines) except Exception as e: # Fallback if resolution fails pass @@ -338,17 +357,29 @@ def _collect_types(p_type, type_list, seen_types=None): seen_types.add(p_type) + is_builtin = False + # 1. Résoudre le type s'il n'est pas déjà un GuardedType + import typing try: + if getattr(p_type, "__origin__", None) is typing.Literal: + is_builtin = True + + if p_type in TypeResolver._PRIMITIVE_MAP: + mapped = TypeResolver._PRIMITIVE_MAP[p_type] + if mapped.__name__ != "GuardedCode": + is_builtin = True + guarded_type = TypeResolver.resolve(p_type) except: pass # 2. Si c'est un type complexe, on l'ajoute à la liste de documentation - type_name = nice_type_name(p_type) - doc = describe_type_as_python(p_type) - if doc and (not doc.startswith("#") or "Description for guarded type" in doc): - type_list[type_name] = doc + if not is_builtin: + type_name = nice_type_name(p_type) + doc = describe_type_as_python(p_type) + if doc: + type_list[type_name] = doc # 3. Récursivité # Generics @@ -384,8 +415,14 @@ def encode_function_parameter_types(analyse: AnalyzedFunction): if analyse.type is not None and analyse.type is not inspect._empty: _collect_types(analyse.type, python_types_definition_list, seen_types) + if python_types_definition_list: + joined_types = "\n\n".join([f"# definition of type {k}:\n{v}" for k, v in python_types_definition_list.items()]) + python_type_definition_dict = f"```python\n{joined_types}\n```" + else: + python_type_definition_dict = "" + return { - "python_type_definition_dict": "\n".join([f"```python\n# definition of type {k}:\n{v}\n```" for k,v in python_types_definition_list.items()]) + "python_type_definition_dict": python_type_definition_dict } def encode_function_parameter_values(analyse: AnalyzedFunction): diff --git a/src/OpenHosta/guarded/resolver.py b/src/OpenHosta/guarded/resolver.py index 9058c42c..b279dcc1 100644 --- a/src/OpenHosta/guarded/resolver.py +++ b/src/OpenHosta/guarded/resolver.py @@ -181,6 +181,10 @@ def resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: if annotation is None: return GuardedNone + # Python 3.12 TypeAliasType + if hasattr(annotation, "__name__") and type(annotation).__name__ == "TypeAliasType": + return cls.resolve(annotation.__value__) + # Enums Python if isinstance(annotation, type) and issubclass(annotation, Enum): # Import GuardedEnum pour wrapper les enums standards diff --git a/src/OpenHosta/guarded/subclassablecollections.py b/src/OpenHosta/guarded/subclassablecollections.py index 61267281..29caf5b4 100644 --- a/src/OpenHosta/guarded/subclassablecollections.py +++ b/src/OpenHosta/guarded/subclassablecollections.py @@ -825,13 +825,11 @@ def __setattr__(self, name, value): fields_repr = [] hints = resolve_struct_hints(cls_to_guard) + from ..core.analizer import nice_type_name for field in field_definitions: field_type = hints.get(field.name, field.type) try: - guarded_field = TypeResolver.resolve(field_type) - type_str = getattr(guarded_field, "_type_py_repr", None) - if type_str is None or type_str is NotImplemented: - type_str = getattr(guarded_field, "__name__", str(field_type)) + type_str = nice_type_name(field_type) except Exception: type_str = str(field_type) fields_repr.append(f" {field.name}: {type_str}") @@ -964,12 +962,10 @@ def unwrap(self): fields_repr = [] hints = resolve_struct_hints(cls_to_guard) + from ..core.analizer import nice_type_name for field_name, field_type in hints.items(): try: - guarded_field = TypeResolver.resolve(field_type) - type_str = getattr(guarded_field, "_type_py_repr", None) - if type_str is None or type_str is NotImplemented: - type_str = getattr(field_type, "__name__", str(field_type)) + type_str = nice_type_name(field_type) except Exception: type_str = getattr(field_type, "__name__", str(field_type)) fields_repr.append(f" {field_name}: {type_str}") From 4c7a5c3aeb7eb60fa6ddea9152d24911c25b9a16 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 17:13:50 +0200 Subject: [PATCH 19/33] =?UTF-8?q?feat:=20Configuration=20flexible=20:=20Aj?= =?UTF-8?q?out=20de=20OPENHOSTA=5FDEFAULT=5FMODEL=5FAPI=5FPARAMETERS=20dan?= =?UTF-8?q?s=20le=20.env=20pour=20piloter=20finement=20les=20param=C3=A8tr?= =?UTF-8?q?es=20du=20mod=C3=A8le=20(ex:=20d=C3=A9sactiver=20le=20reasoning?= =?UTF-8?q?).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OpenHosta/defaults.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/OpenHosta/defaults.py b/src/OpenHosta/defaults.py index 175ae04d..ba958b8c 100644 --- a/src/OpenHosta/defaults.py +++ b/src/OpenHosta/defaults.py @@ -141,6 +141,7 @@ def reload_dotenv(override: bool = True, dotenv_path="./.env"): TOP_P = os.getenv("OPENHOSTA_DEFAULT_MODEL_TOP_P", None) MAX_TOKENS = os.getenv("OPENHOSTA_DEFAULT_MODEL_MAX_TOKENS", None) SEED = os.getenv("OPENHOSTA_DEFAULT_MODEL_SEED", None) + API_PARAMETERS = os.getenv("OPENHOSTA_DEFAULT_MODEL_API_PARAMETERS", None) LOGPROBS_SUPPORT = str(os.getenv("OPENHOSTA_DEFAULT_MODEL_LOGPROBS_SUPPORT", "False")).lower() in ("true", "1", "t", "yes") config.MAX_RETRIES = int(os.getenv("OPENHOSTA_MAX_RETRIES", 3)) @@ -155,6 +156,14 @@ def reload_dotenv(override: bool = True, dotenv_path="./.env"): if SEED is not None: _defaut_model.api_parameters |= {"seed": int(SEED)} + if API_PARAMETERS is not None: + import json + try: + params = json.loads(API_PARAMETERS) + _defaut_model.api_parameters |= params + except Exception as e: + sys.stderr.write(f"[OpenHosta/CONFIG_ERROR] Failed to parse OPENHOSTA_DEFAULT_MODEL_API_PARAMETERS as JSON: {e}\n") + if LOGPROBS_SUPPORT is True: _defaut_model.capabilities |= { ModelCapabilities.LOGPROBS } From afb547b59c6c7f86e9132018b2a2ba506294b10b Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 17:14:27 +0200 Subject: [PATCH 20/33] feat: Introspection des Callables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'utilisation de GuardedCallable permet de récupérer et d'afficher le code source généré via str(result). --- docs/types_and_pydantic.md | 24 +++++++++++++ src/OpenHosta/guarded/__init__.py | 2 ++ .../guarded/subclassablecallables.py | 35 +++++++++++++++++-- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/docs/types_and_pydantic.md b/docs/types_and_pydantic.md index 7c12045e..38ad4e06 100644 --- a/docs/types_and_pydantic.md +++ b/docs/types_and_pydantic.md @@ -45,3 +45,27 @@ print(find_first_name("The captain's age is one year more than the first officer In OpenHosta V4, **Guarded Types** provide a robust abstraction for ensuring type structural and semantic validitiy behind the scenes. This handles strict validation rules during extraction pipelines. See [Guarded Types Overview](guarded.md) for more details. + +## Python 3.12 Type Aliases + +Starting from Python 3.12, the `type` statement allows creating type aliases natively (PEP 695). +OpenHosta supports this syntax out-of-the-box and will accurately map your aliases into the LLM prompt. + +```python +from typing import Literal +from dataclasses import dataclass +from OpenHosta import emulate + +type RegimeMatrimonial = Literal["Marié", "PACS", "Concubin"] + +@dataclass +class Family: + name: str + status: RegimeMatrimonial + +def identify_family(document: str) -> Family: + """Extract family information""" + return emulate() +``` + +OpenHosta will correctly display the alias name `RegimeMatrimonial` in the `Family` structure definition and document the alias itself separately in the prompt to provide clear context to the LLM. diff --git a/src/OpenHosta/guarded/__init__.py b/src/OpenHosta/guarded/__init__.py index d4a40a7e..54d3d5ee 100644 --- a/src/OpenHosta/guarded/__init__.py +++ b/src/OpenHosta/guarded/__init__.py @@ -8,6 +8,7 @@ from .subclassableclasses import GuardedEnum from .subclassableliterals import GuardedLiteral, guarded_literal from .subclassableunions import GuardedUnion, guarded_union +from .subclassablecallables import GuardedCode as GuardedCallable from .resolver import TypeResolver, type_returned_data __all__ = [ @@ -44,6 +45,7 @@ 'GuardedEnum', 'GuardedLiteral', 'GuardedUnion', + 'GuardedCallable', 'guarded_dataclass', 'guarded_literal', 'guarded_union', diff --git a/src/OpenHosta/guarded/subclassablecallables.py b/src/OpenHosta/guarded/subclassablecallables.py index 8ef34bbb..88e93631 100644 --- a/src/OpenHosta/guarded/subclassablecallables.py +++ b/src/OpenHosta/guarded/subclassablecallables.py @@ -7,12 +7,25 @@ from .primitives import GuardedPrimitive, UncertaintyLevel, Tolerance, ProxyWrapper class GuardedCode(GuardedPrimitive, ProxyWrapper): - # TODO: implement using scalars.py as example _type_en = "the complete Python source code of the function (starting with 'def'), NOT its string representation (like )" _type_py = Callable _type_json = {"type": "string", "description": "Python source code of a function, do NOT output representations"} _type_knowledge = {"local_scope": {}} + def __str__(self): + if hasattr(self, "_input") and self._input is not None: + return str(self._input) + if hasattr(self, "_python_value") and self._python_value is not None: + import inspect + try: + return inspect.getsource(self._python_value) + except Exception: + return str(self._python_value) + return "None" + + def __repr__(self): + return self.__str__() + @classmethod def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: # Si c'est déjà une fonction ou une méthode, c'est valide. @@ -32,12 +45,20 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if not cleaned_code: return UncertaintyLevel(Tolerance.ANYTHING), value, "Empty source code" + # If it looks like a JSON-encoded string (starts and ends with quotes and contains escapes) + if (cleaned_code.startswith('"') and cleaned_code.endswith('"')) or (cleaned_code.startswith("'") and cleaned_code.endswith("'")): + try: + # Try to decode escapes using global ast module + cleaned_code = ast.literal_eval(cleaned_code) + except Exception: + cleaned_code = cleaned_code.strip("'\"") + if "```" in cleaned_code: lines = cleaned_code.splitlines() start_idx = -1 end_idx = -1 for i, line in enumerate(lines): - if line.strip().startswith("```"): + if "```" in line: if start_idx == -1: start_idx = i else: @@ -47,6 +68,16 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if start_idx != -1 and end_idx != -1: # We found a code block, extract it cleaned_code = "\n".join(lines[start_idx + 1 : end_idx]) + elif start_idx != -1: + # Open block but not closed, take everything after + cleaned_code = "\n".join(lines[start_idx + 1 :]) + + cleaned_code = cleaned_code.strip() + + # Final check for literal backslash-n if still present + if "\\n" in cleaned_code and "\n" not in cleaned_code: + cleaned_code = cleaned_code.replace("\\n", "\n").replace("\\t", " ") + # 2. Vérification Syntaxique (AST) try: From 6fee8389fe994a7aa80f08e6adb1496f89b76450 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 17:26:20 +0200 Subject: [PATCH 21/33] tests: add tests for new features --- tests/functionnal/test_callable.py | 23 +++++++++++++++++++++++ tests/functionnal/test_type_alias.py | 25 +++++++++++++++++++++++++ tests/guarded/test_callables.py | 16 ++++++++++++++++ tests/guarded/test_resolver.py | 26 ++++++++++++++++++++------ 4 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 tests/functionnal/test_callable.py create mode 100644 tests/functionnal/test_type_alias.py diff --git a/tests/functionnal/test_callable.py b/tests/functionnal/test_callable.py new file mode 100644 index 00000000..686d239b --- /dev/null +++ b/tests/functionnal/test_callable.py @@ -0,0 +1,23 @@ +import pytest +from OpenHosta.guarded import GuardedCallable +from OpenHosta import emulate + +def generate_calculator() -> GuardedCallable: + """ + Create a python function that takes two numbers and returns their sum. + The function must be named 'add'. + """ + return emulate() + +@pytest.mark.asyncio +async def test_callable_returns_guarded_and_prints_source(): + # Emulate should return a GuardedCallable wrapper + result = generate_calculator() + + # We should be able to call it + assert result(2, 3) == 5 + + # And we should be able to print its source code + source = str(result) + assert "def add" in source + assert "return" in source diff --git a/tests/functionnal/test_type_alias.py b/tests/functionnal/test_type_alias.py new file mode 100644 index 00000000..9e636d3d --- /dev/null +++ b/tests/functionnal/test_type_alias.py @@ -0,0 +1,25 @@ +import pytest +from typing import Literal +from dataclasses import dataclass +from OpenHosta import emulate + +type RegimeMatrimonial = Literal["Marié", "PACS", "Concubin"] + +@dataclass +class Family: + name: str + status: RegimeMatrimonial + +def identify_family(document: str) -> Family: + """ + Extract the family information from the given text. + """ + return emulate() + +@pytest.mark.asyncio +async def test_type_alias_literal(): + doc = "Jean et Marie sont liés par un PACS." + result = identify_family(doc) + assert isinstance(result, Family) + assert result.status == "PACS" + assert result.name == "Jean et Marie" diff --git a/tests/guarded/test_callables.py b/tests/guarded/test_callables.py index 37d23c68..baa86526 100644 --- a/tests/guarded/test_callables.py +++ b/tests/guarded/test_callables.py @@ -68,3 +68,19 @@ def __call__(self, x, y): code = GuardedCode(Adder()) assert code(1, 1) == 2 assert code.uncertainty == Tolerance.STRICT + + def test_source_code_introspection(self): + """Test that str(code) returns the source code.""" + source = "def power(x, y): return x ** y" + code = GuardedCode(source) + + # str(code) should return the input source code + assert str(code) == source + assert "def power" in str(code) + + # Test with markdown wrapping + source_md = "```python\ndef sub(a, b): return a - b\n```" + code_md = GuardedCode(source_md) + assert str(code_md) == source_md + assert code_md(10, 3) == 7 + diff --git a/tests/guarded/test_resolver.py b/tests/guarded/test_resolver.py index 78de1ae3..70b9b9f1 100644 --- a/tests/guarded/test_resolver.py +++ b/tests/guarded/test_resolver.py @@ -1,5 +1,5 @@ import pytest -from typing import List, Dict, Set, Tuple, Optional, Union +from typing import List, Dict, Set, Tuple, Optional, Union, Literal from OpenHosta.guarded.resolver import TypeResolver, type_returned_data from OpenHosta.guarded.subclassablescalars import GuardedInt, GuardedUtf8, GuardedFloat from OpenHosta.guarded.subclassablecollections import GuardedList, GuardedDict, GuardedSet, GuardedTuple @@ -304,13 +304,27 @@ def test_resolve_literal(self): # Should now return a GuardedLiteral (dynamic class) assert "Literal" in str(resolved) or resolved.__name__.startswith("Literal") - def test_resolve_literal_int(self): - """Test resolving Literal with integers.""" - from typing import Literal - - resolved = TypeResolver.resolve(Literal[1, 2, 3]) # Should return a GuardedLiteral based on GuardedInt assert "Literal" in str(resolved) or resolved.__name__.startswith("Literal") + + def test_resolve_type_alias_pep695(self): + """Test resolving Python 3.12+ TypeAliasType (PEP 695).""" + import sys + if sys.version_info < (3, 12): + pytest.skip("TypeAliasType (PEP 695) requires Python 3.12+") + + from typing import Literal + # This syntax is only valid in Python 3.12+ + # Provide Literal in the namespace for exec + namespace = {"Literal": Literal} + exec("type MyAlias = Literal['a', 'b']", globals(), namespace) + MyAlias = namespace["MyAlias"] + + + resolved = TypeResolver.resolve(MyAlias) + assert "Literal" in str(resolved) + assert "a" in str(resolved) and "b" in str(resolved) + def test_resolve_custom_guarded_type(self): """Test resolving custom GuardedPrimitive subclass.""" From 56354ff08b2aecf2abaa4872e26d5ff2827a7f3c Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 18:16:45 +0200 Subject: [PATCH 22/33] docs: Document env variables for production --- docs/getting_started.md | 3 + docs/index.md | 3 + docs/models_and_setup.md | 2 + docs/production.md | 92 ++++++++++++++++++++++++++++++ next/liste_idees_a_selectionner.md | 57 ++++++++++++++++++ src/OpenHosta/defaults.py | 44 ++++++++------ 6 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 docs/production.md create mode 100644 next/liste_idees_a_selectionner.md diff --git a/docs/getting_started.md b/docs/getting_started.md index 3a2e0011..397c64ff 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -31,6 +31,9 @@ OPENHOSTA_DEFAULT_MODEL_NAME="gpt-4.1" # Default OPENHOSTA_DEFAULT_MODEL_TEMPERATURE=0.7 # Optional OPENHOSTA_DEFAULT_MODEL_SEED=42 # Optional. Deterministic for local LLMs OPENHOSTA_RATE_LIMIT_WAIT_TIME=60 # Optional +OPENHOSTA_AUDIT_MODE=False # Optional. Enable structured audit logs +OPENHOSTA_SILENCE_ENV_WARNING=False # Optional. Silence .env missing warnings +OPENHOSTA_DEFAULT_MODEL_API_PARAMETERS='{"top_k":5}' # Optional. JSON string for extra model params ``` ### *Legal Framework* diff --git a/docs/index.md b/docs/index.md index c9079c46..7712d4f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,9 @@ Simplify concurrent execution of `emulate_async` with the `gather_data` batching ### 📐 [Guarded Types](guarded.md) Deep dive into OpenHosta's type validation and conversion system with configurable tolerance. +### 🛡️ [Production & Auditing](production.md) +Learn how to deploy OpenHosta at scale, enable audit logging for compliance, and track token usage in production. + --- ## Cookbook diff --git a/docs/models_and_setup.md b/docs/models_and_setup.md index 6a747d38..0abbaeb7 100644 --- a/docs/models_and_setup.md +++ b/docs/models_and_setup.md @@ -69,6 +69,8 @@ from OpenHosta import config config.AUDIT_MODE = True ``` +For more details on audit events, compliance, and production deployment, see the **[Production & Auditing Guide](production.md)**. + ## Cost Tracking Use `track_costs` context manager to count tokens. ```python diff --git a/docs/production.md b/docs/production.md new file mode 100644 index 00000000..f32f2691 --- /dev/null +++ b/docs/production.md @@ -0,0 +1,92 @@ +# Production & Auditing + +When moving an OpenHosta application from development to production, **observability** and **traceability** become critical. OpenHosta provides a built-in auditing system to help you monitor LLM interactions, track costs, and ensure compliance. + +--- + +## 🛡️ The Audit Mode + +The `audit_log` function is the core of OpenHosta's observability. It records structured events during the execution of your pipelines. + +### Why Audit? (Industry Standards) +In modern software engineering, **Audit Logging** is more than just debugging; it is a standard for: +- **Compliance**: Meeting regulatory requirements (GDPR, SOC2, HIPAA) by tracking data lineage. +- **Security**: Detecting prompt injections or unexpected model behaviors. +- **Accountability**: Providing a "black box" recording of AI decisions. +- **Optimization**: Analyzing real-world usage to improve prompt performance and reduce costs. + +### Enabling Audit Mode +By default, auditing is disabled to save performance. You can enable it via the global configuration: + +```python +from OpenHosta import config + +config.AUDIT_MODE = True +``` + +Alternatively, you can enable it via an environment variable: +```bash +export OPENHOSTA_AUDIT_MODE=True +``` + +Once enabled, OpenHosta will output JSON-structured logs to `stdout` prefixed with `[OPENHOSTA_AUDIT]`. + +--- + +## ⚙️ Advanced Production Setup + +### 1. Centralizing Logs +In production, you should redirect `stdout` to a log aggregator (Datadog, ELK, CloudWatch). You can easily filter OpenHosta audit logs using the prefix: + +```bash +# Example: Grepping audit logs in a Linux environment +python my_app.py | grep "[OPENHOSTA_AUDIT]" > audit.log +``` + +### 2. Programmatic Callbacks +For deeper integration, you can register custom callbacks. This is useful for sending alerts to **Sentry**, saving events to a **SQL database**, or pushing metrics to **Prometheus**. + +```python +from OpenHosta.core.audit import register_audit_callback, AuditEvent + +def my_production_handler(event: AuditEvent): + # Example: Send to an external monitoring tool + if event.event_type == "llm_call_error": + print(f"CRITICAL: Model failed with details: {event.details}") + + # Save to your own DB for analytics + save_to_db(event.timestamp, event.event_type, event.details) + +register_audit_callback(my_production_handler) +``` + +### 3. Cost Control +Always wrap production calls in a cost tracker to avoid budget overruns. + +```python +from OpenHosta import emulate, track_costs + +with track_costs() as tracker: + result = emulate("Analyze this report", data=my_report) + +# Log this to your billing system +print(f"Cost for this request: {tracker.total_tokens} tokens") +``` + +--- + +## 🚀 Production Checklist + +- [ ] **API Keys**: Use environment variables (`OPENHOSTA_DEFAULT_MODEL_API_KEY`). +- [ ] **Silence Warnings**: Set `OPENHOSTA_SILENCE_ENV_WARNING=True`. + > [!IMPORTANT] + > In containerized environments (Docker, Kubernetes) or application servers where you pass configuration via environment variables instead of a `.env` file, set this to `True` to prevent OpenHosta from printing "missing .env" warnings, ensuring clean and professional startup logs. +- [ ] **Timeouts**: Ensure your model server (Ollama, vLLM, OpenAI) has appropriate timeout settings. +- [ ] **Retries**: OpenHosta handles basic retries, but consider implementing a circuit breaker for high-traffic apps. +- [ ] **Audit Enabled**: Enable `AUDIT_MODE` if you are in a regulated industry. +- [ ] **Fallback Models**: Configure a secondary model in case your primary provider is down. + +--- + +> [!TIP] +> Audit logs are structured as JSON. They include `timestamp`, `event_type`, and `details` (which contains the raw prompt, the model response, and the parsed result). diff --git a/next/liste_idees_a_selectionner.md b/next/liste_idees_a_selectionner.md new file mode 100644 index 00000000..d73b31e8 --- /dev/null +++ b/next/liste_idees_a_selectionner.md @@ -0,0 +1,57 @@ +#### Planter des bandes de couvre-sol comme le trèfle blanc autour des cultures pour limiter l'évaporation, enrichir le sol en azote et créer un habitat pour les auxiliaires contre les limaces. +--- + +#### Installer un système de paillage épais avec des feuilles mortes et de l'herbe tondue pour protéger le sol tourbeux, réduire l'arrosage et empêcher la prolifération des limaces en leur ôtant des abris. +--- + +#### Placer des cultures grimpantes comme les haricots et les pois gourmands sur des tipis en bois léger près de la palissade, orientés sud-ouest pour capter un maximum de lumière tout en préservant l'espace. +--- + +#### Introduire des plantes compagnes répulsives pour les limaces, comme l'ail, la ciboulette et le thym, autour des salades et des jeunes pousses vulnérables. +--- + +#### Prévoir des cultures en successions échelonnées pour les radis et les salades afin d’étaler les récoltes sur toute la saison et éviter les surplus en une seule fois. +--- + +#### Utiliser des bouteilles en plastique coupées comme protections individuelles contre les limaces pour les jeunes plants, méthode peu coûteuse et facile à déplacer. +--- + +#### Planifier une association de plantes en guildes autour des framboisiers existants, incluant du fenouil, de la coriandre et de l’aneth pour attirer les insectes bénéfiques et améliorer la pollinisation. +--- + +#### Créer un petit banc en bois près du jardin pour encourager des observations régulières et des interventions douces, adaptées à une disponibilité limitée de 1h/jour, facilitant un suivi permacole serein. +--- + +#### Utiliser des bacs de culture en bois ou en plastique recyclé installés près de la maison pour cultiver les salades et les radis, permettant un contrôle facile du sol et de l'humidité tout en évitant les limaces grâce à une élévation simple. +--- + +#### Créer un semis en carrés successifs pour les haricots et les pois gourmands, espacés de deux semaines, afin d’échelonner la récolte et mieux adapter l’entretien à 1h/jour. +--- + +#### Planter des fleurs comestibles comme les capucines et les soucis autour des légumes pour repousser naturellement les ravageurs et attirer les pollinisateurs, tout en ajoutant de la diversité alimentaire. +--- + +#### Mettre en place un arrosage manuel ciblé le matin, en utilisant un arrosoir avec une rose fine, pour éviter l’humidité stagnante qui attire les limaces, tout en s’adaptant aux besoins réels des plantes. +--- + +#### Délimiter une petite zone ensoleillée pour un carré de pommes de terre en sacs ou en grands contenants, facile à gérer et à vidanger en fin de saison, sans perturber le sol existant. +--- + +#### Utiliser du marc de café récupéré localement, épandu modérément autour des jeunes plants sensibles, pour repousser les limaces de manière simple et renouvelable. +--- + +#### Utiliser des bâtonnets de cendre de bois autour des jeunes pousses comme barrière naturelle contre les limaces, en alternance avec les anneaux de cuivre pour varier les protections et éviter l’habitude. +--- + +#### Planter des pieds de fraisiers en carrés espacés près des framboisiers existants, en les protégeant avec des rondelles de bouteilles en plastique pour limiter les limaces et favoriser une microzone de chaleur. +--- + +#### Semer des radis en lignes courtes toutes les deux semaines dans des bacs surélevés, en alternant avec de la roquette et de la cresson pour une diversité rapide de microsalades sans rotation de sol. +--- + +#### Mettre en œuvre un arrosage au goutte-à-goutte simple avec des bouteilles en plastique percées enterrées à côté des pieds de tomates, pour une irrigation lente et ciblée le matin. +--- + +#### Utiliser des feuilles de chou ramassées localement comme paillage temporaire autour des salades, pour créer une barrière physique contre les limaces tout en s’intégrant au compost progressivement. +--- + diff --git a/src/OpenHosta/defaults.py b/src/OpenHosta/defaults.py index ba958b8c..9a175773 100644 --- a/src/OpenHosta/defaults.py +++ b/src/OpenHosta/defaults.py @@ -89,32 +89,38 @@ def reload_dotenv(override: bool = True, dotenv_path="./.env"): if _dotenv_availbale: dotenv_path = os.path.abspath(dotenv_path) dotenv_find_path = recursive_find_dotenv(os.path.dirname(dotenv_path)) + silence_warning = os.getenv("OPENHOSTA_SILENCE_ENV_WARNING", "False").lower() in ("true", "1", "t", "yes") + if dotenv_path == dotenv_find_path: # There is a .env file at the specified path. This is good for production pass elif dotenv_find_path is not None: # There is a .env file in a parent directory. This is good for development. - sys.stderr.write(f"[OpenHosta/CONFIG_WARNING] .env file not found at {dotenv_path}. Using {dotenv_find_path} instead.\n") + if not silence_warning: + sys.stderr.write(f"[OpenHosta/CONFIG_WARNING] .env file not found at {dotenv_path}. Using {dotenv_find_path} instead.\n") dotenv_path = dotenv_find_path else: - # There is no .env file. This is bad. - sys.stderr.write(f"[OpenHosta/CONFIG_WARNING] .env file not found at {dotenv_path} or in any parent directory.\n") - sys.stderr.write(textwrap.dedent("""\ - [OpenHosta/CONFIG_ERROR] .env file not found. It is a good practice to store your credentials in a .env file. - Example .env file: - ------------------ - OPENHOSTA_DEFAULT_MODEL_API_KEY="your_api_key" - OPENHOSTA_DEFAULT_MODEL_BASE_URL="https://api.openai.com/v1" # Optional - OPENHOSTA_DEFAULT_MODEL_NAME="gpt-5" # Default to "gpt-4.1" - OPENHOSTA_DEFAULT_MODEL_TEMPERATURE=0.7 # Optional - OPENHOSTA_DEFAULT_MODEL_TOP_P=0.9 # Optional - OPENHOSTA_DEFAULT_MODEL_MAX_TOKENS=2048 # Optional - OPENHOSTA_DEFAULT_MODEL_RATE_LIMIT_WAIT_TIME=60 # Optional. Set to 0 to prevent retry. Unit is seconds. - OPENHOSTA_DEFAULT_MODEL_SEED=42 # Optional. If set with a local LLM your application will be deterministic. - OPENHOSTA_MAX_RETRIES=3 # Optional. Maximum number of parsing retry attempts. - OPENHOSTA_AUDIT_MODE=False # Optional. Set to True to enable verbose audit logging. - ------------------ - """)) + # There is no .env file. + if not silence_warning: + sys.stderr.write(f"[OpenHosta/CONFIG_WARNING] .env file not found at {dotenv_path} or in any parent directory.\n") + sys.stderr.write(textwrap.dedent("""\ + [OpenHosta/CONFIG_ERROR] .env file not found. It is a good practice to store your credentials in a .env file. + Example .env file: + ------------------ + OPENHOSTA_DEFAULT_MODEL_API_KEY="your_api_key" + OPENHOSTA_DEFAULT_MODEL_BASE_URL="https://api.openai.com/v1" # Optional + OPENHOSTA_DEFAULT_MODEL_NAME="gpt-5" # Default to "gpt-4.1" + OPENHOSTA_DEFAULT_MODEL_TEMPERATURE=0.7 # Optional + OPENHOSTA_DEFAULT_MODEL_TOP_P=0.9 # Optional + OPENHOSTA_DEFAULT_MODEL_MAX_TOKENS=2048 # Optional + OPENHOSTA_DEFAULT_MODEL_RATE_LIMIT_WAIT_TIME=60 # Optional. Set to 0 to prevent retry. Unit is seconds. + OPENHOSTA_DEFAULT_MODEL_SEED=42 # Optional. If set with a local LLM your application will be deterministic. + OPENHOSTA_MAX_RETRIES=3 # Optional. Maximum number of parsing retry attempts. + OPENHOSTA_AUDIT_MODE=False # Optional. Set to True to enable verbose audit logging. + OPENHOSTA_SILENCE_ENV_WARNING=False # Optional. Set to True to disable this warning. + ------------------ + """)) + if not load_dotenv(dotenv_path=dotenv_path, override=override): sys.stderr.write(f"[OpenHosta/CONFIG_ERROR] Failed to load .env file at {dotenv_path}.\n") From 658313cc5bf6252294b1ac36665c5d32b48f83d8 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 18:36:39 +0200 Subject: [PATCH 23/33] refacto: Unification des blocs de code dans le template. --- src/OpenHosta/core/analizer.py | 2 +- src/OpenHosta/core/meta_prompt.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index 8555466d..58a26edc 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -417,7 +417,7 @@ def encode_function_parameter_types(analyse: AnalyzedFunction): if python_types_definition_list: joined_types = "\n\n".join([f"# definition of type {k}:\n{v}" for k, v in python_types_definition_list.items()]) - python_type_definition_dict = f"```python\n{joined_types}\n```" + python_type_definition_dict = joined_types else: python_type_definition_dict = "" diff --git a/src/OpenHosta/core/meta_prompt.py b/src/OpenHosta/core/meta_prompt.py index f9286f34..74407a56 100644 --- a/src/OpenHosta/core/meta_prompt.py +++ b/src/OpenHosta/core/meta_prompt.py @@ -109,11 +109,11 @@ def __repr__(self): explain it in between tags.{% endif %} Here's the function definition: - - {{ python_type_definition_dict }} ```python - def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: + {% if python_type_definition_dict %}{{ python_type_definition_dict }} + + {% endif %}def {{ function_name }}({{ function_args }}) -> {{ function_return_type_name }}: \"\"\" {{ function_doc | indent(4, true) }} \"\"\" From 76605d69c31ed96bac5d3b22c85a58bd4aaaa677 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 18:37:03 +0200 Subject: [PATCH 24/33] =?UTF-8?q?fix:=20Ajout=20des=20caches=20sp=C3=A9cif?= =?UTF-8?q?iques=20aux=20factories=20et=20simplification=20de=20GuardedEnu?= =?UTF-8?q?m.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OpenHosta/guarded/subclassablecollections.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/OpenHosta/guarded/subclassablecollections.py b/src/OpenHosta/guarded/subclassablecollections.py index 29caf5b4..cebac9de 100644 --- a/src/OpenHosta/guarded/subclassablecollections.py +++ b/src/OpenHosta/guarded/subclassablecollections.py @@ -599,10 +599,17 @@ def unwrap(self): """Retourne un tuple natif avec unwrapping récursif des éléments.""" return self._recursive_unwrap(tuple(self)) -def guarded_tuple(*item_types): +# Cache pour éviter de recréer la même classe wrapper +_GUARDED_TUPLE_CACHE: dict = {} +def guarded_tuple(*item_types): """Factory for parameterized tuples.""" - return GuardedTuple[item_types] + if item_types in _GUARDED_TUPLE_CACHE: + return _GUARDED_TUPLE_CACHE[item_types] + + res = GuardedTuple[item_types] + _GUARDED_TUPLE_CACHE[item_types] = res + return res # Cache pour éviter de reconstruire la même classe multiple fois _DATACLASS_GUARDED_CACHE: Any = {} From 79a804df5170ab79a619df6a12cfb674e0667a41 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 18:37:34 +0200 Subject: [PATCH 25/33] =?UTF-8?q?fix:=20=20Ajout=20du=20cache=20centralis?= =?UTF-8?q?=C3=A9=20pour=20unifier=20les=20Guarded=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/OpenHosta/guarded/resolver.py | 13 +++++++++++-- src/OpenHosta/guarded/subclassablecallables.py | 7 +++++++ src/OpenHosta/guarded/subclassableclasses.py | 9 ++++++++- src/OpenHosta/guarded/subclassableliterals.py | 7 +++++++ src/OpenHosta/guarded/subclassableunions.py | 7 +++++++ 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/OpenHosta/guarded/resolver.py b/src/OpenHosta/guarded/resolver.py index b279dcc1..4cfcbd0b 100644 --- a/src/OpenHosta/guarded/resolver.py +++ b/src/OpenHosta/guarded/resolver.py @@ -125,6 +125,8 @@ class TypeResolver: typing.Any: GuardedAny, } + _RESOLVE_CACHE: Dict[Any, Type[GuardedPrimitive]] = {} + @classmethod def resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: """ @@ -136,7 +138,15 @@ def resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: - Dict[str, float] -> GuardedDict[GuardedUtf8, GuardedFloat] - GuardedInt -> GuardedInt (Idempotence) """ + if annotation in cls._RESOLVE_CACHE: + return cls._RESOLVE_CACHE[annotation] + result = cls._do_resolve(annotation) + cls._RESOLVE_CACHE[annotation] = result + return result + + @classmethod + def _do_resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: # 0. Safety net: Stringified annotations (from __future__ import annotations) # NOTE: This should rarely trigger now that analizer.py resolves strings via _resolve_annotation. # TODO(Phase 3): Remove this once all get_type_hints call sites are centralized. @@ -298,5 +308,4 @@ def resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: # 6. Fallback raise TypeError(f"Type {annotation} (origin: {origin}) is not supported by OpenHosta TypeResolver. " - f"Consider using a supported primitive or wrapping your custom type.") - \ No newline at end of file + f"Consider using a supported primitive or wrapping your custom type.") \ No newline at end of file diff --git a/src/OpenHosta/guarded/subclassablecallables.py b/src/OpenHosta/guarded/subclassablecallables.py index 88e93631..3cd8c5ca 100644 --- a/src/OpenHosta/guarded/subclassablecallables.py +++ b/src/OpenHosta/guarded/subclassablecallables.py @@ -110,12 +110,18 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s except Exception as e: return UncertaintyLevel(Tolerance.ANYTHING), value, str(e) +# Cache pour éviter de recréer la même classe wrapper +_GUARDED_CALLABLE_CACHE: dict = {} + def guarded_callable(*args): """ Creates a dynamic subclass of GuardedCode. Injects the provided argument and return types into the `local_scope` so that `exec()` can access them when executing the generated string. """ + if args in _GUARDED_CALLABLE_CACHE: + return _GUARDED_CALLABLE_CACHE[args] + class GuardedCallableWrapper(GuardedCode): _type_knowledge = {"local_scope": {}} @@ -131,4 +137,5 @@ class GuardedCallableWrapper(GuardedCode): name = native_type.__name__ GuardedCallableWrapper._type_knowledge["local_scope"][name] = native_type + _GUARDED_CALLABLE_CACHE[args] = GuardedCallableWrapper return GuardedCallableWrapper diff --git a/src/OpenHosta/guarded/subclassableclasses.py b/src/OpenHosta/guarded/subclassableclasses.py index 2502f08d..5b82a762 100644 --- a/src/OpenHosta/guarded/subclassableclasses.py +++ b/src/OpenHosta/guarded/subclassableclasses.py @@ -54,7 +54,7 @@ def __init_subclass__(cls, **kwargs): if not name.startswith('_') and not callable(value): cls._members[name] = value - cls._type_en = f"a value from {cls.__name__} enum:\n\n" + cls._build_type_py_repr() + "\n" + cls._type_en = f"a value from {cls.__name__} enum" cls._type_py = str cls._type_py_repr = cls._build_type_py_repr() @@ -204,12 +204,18 @@ def value(self): return self._members.get(self._python_value) +# Cache pour éviter de recréer la même classe wrapper +_GUARDED_ENUM_CACHE: dict = {} + def guarded_enum(enum_cls: Type[Enum]) -> Type[GuardedEnum]: """ Factory pour transformer une Enum standard en GuardedEnum. """ if issubclass(enum_cls, GuardedEnum): return enum_cls + + if enum_cls in _GUARDED_ENUM_CACHE: + return _GUARDED_ENUM_CACHE[enum_cls] attrs = {} for name, member in enum_cls.__members__.items(): @@ -220,4 +226,5 @@ def guarded_enum(enum_cls: Type[Enum]) -> Type[GuardedEnum]: WrappedEnum._native_class = enum_cls WrappedEnum.__doc__ = enum_cls.__doc__ + _GUARDED_ENUM_CACHE[enum_cls] = WrappedEnum return WrappedEnum diff --git a/src/OpenHosta/guarded/subclassableliterals.py b/src/OpenHosta/guarded/subclassableliterals.py index af4ed5eb..d4fd58ad 100644 --- a/src/OpenHosta/guarded/subclassableliterals.py +++ b/src/OpenHosta/guarded/subclassableliterals.py @@ -8,6 +8,9 @@ from .subclassablescalars import GuardedUtf8, GuardedInt, GuardedFloat +# Cache pour éviter de recréer la même classe wrapper +_GUARDED_LITERAL_CACHE: dict = {} + def guarded_literal(*values): """ Factory pour créer dynamiquement un GuardedLiteral avec des valeurs spécifiques. @@ -32,6 +35,9 @@ def guarded_literal(*values): >>> StatusType = TypeResolver.resolve(Literal["pending", "active"]) >>> status = StatusType("pending") """ + if values in _GUARDED_LITERAL_CACHE: + return _GUARDED_LITERAL_CACHE[values] + if not values: # Pas de valeurs, retourner GuardedUtf8 par défaut return GuardedUtf8 @@ -112,6 +118,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s return UncertaintyLevel(Tolerance.ANYTHING), value, f"Value must be one of {cls._allowed_values}" DynamicLiteral.__name__ = f"Literal[{', '.join(repr(v) for v in values[:3])}{'...' if len(values) > 3 else ''}]" + _GUARDED_LITERAL_CACHE[values] = DynamicLiteral return DynamicLiteral diff --git a/src/OpenHosta/guarded/subclassableunions.py b/src/OpenHosta/guarded/subclassableunions.py index 233b2c50..7bbab306 100644 --- a/src/OpenHosta/guarded/subclassableunions.py +++ b/src/OpenHosta/guarded/subclassableunions.py @@ -71,6 +71,9 @@ def __new__(cls, value: Any, tolerance: Tolerance = None): return winning_type(value) +# Cache pour éviter de recréer la même classe wrapper +_GUARDED_UNION_CACHE: dict = {} + def guarded_union(*guarded_types): """ Factory pour créer un type GuardedUnion. @@ -81,6 +84,9 @@ def guarded_union(*guarded_types): Returns: Une classe GuardedUnion configurée """ + if guarded_types in _GUARDED_UNION_CACHE: + return _GUARDED_UNION_CACHE[guarded_types] + # Résoudre les types pour s'assurer qu'ils ont .attempt() from .resolver import TypeResolver resolved_types = tuple(TypeResolver.resolve(t) for t in guarded_types) @@ -94,4 +100,5 @@ class DynamicUnion(GuardedUnion): } DynamicUnion.__name__ = f"Union[{', '.join(t.__name__ if hasattr(t, '__name__') else str(t) for t in resolved_types)}]" + _GUARDED_UNION_CACHE[guarded_types] = DynamicUnion return DynamicUnion From 19bffc2d51be0f49e998372b54a11b145a7ab6db Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Fri, 24 Apr 2026 19:33:58 +0200 Subject: [PATCH 26/33] refactor: implement robust LLM response cleaning and add comprehensive edge-case parsing tests for guarded types --- docs/guarded.md | 70 +++++++ src/OpenHosta/guarded/primitives.py | 79 ++++++++ src/OpenHosta/guarded/subclassableclasses.py | 3 +- .../guarded/subclassablecollections.py | 10 +- src/OpenHosta/guarded/subclassableliterals.py | 11 +- src/OpenHosta/guarded/subclassablepydantic.py | 2 +- src/OpenHosta/guarded/subclassablescalars.py | 44 +++-- .../guarded/subclassablewithproxy.py | 26 +-- tests/guarded/test_attempt_borderlines.py | 187 ++++++++++++++++++ tests/guarded/test_direct_parsing.py | 98 +++++++++ 10 files changed, 486 insertions(+), 44 deletions(-) create mode 100644 tests/guarded/test_attempt_borderlines.py create mode 100644 tests/guarded/test_direct_parsing.py diff --git a/docs/guarded.md b/docs/guarded.md index d230273c..fb583b37 100644 --- a/docs/guarded.md +++ b/docs/guarded.md @@ -1083,3 +1083,73 @@ port = GuardedInt(config["port"]) # 8080 - `subclassablecollections.py` - Collections and dataclasses - `subclassableclasses.py` - GuardedEnum - `resolver.py` - Type resolution + +--- + +## X. Direct Usage: Parsing LLM Output + +While OpenHosta usually handles parsing automatically via `emulate()`, you can use Guarded types directly to parse raw strings from any LLM client (OpenAI, Anthropic, LangChain, etc.). + +### 10.1 Using TypeResolver + +The most robust way to parse a string into a specific type is to use `TypeResolver.resolve()`. It handles all Python annotations (List, Dict, Dataclasses, etc.). + +```python +from typing import List +from OpenHosta.guarded.resolver import TypeResolver + +# 1. Define your expected type +MyType = List[int] + +# 2. Get the LLM output (raw string) +raw_output = "I found these numbers: [10, 20, 30] # and some noise" + +# 3. Resolve the type and parse +guarded_type = TypeResolver.resolve(MyType) +result = guarded_type.attempt(raw_output) + +if result.success: + data = result.data # [10, 20, 30] + print(f"Parsed {len(data)} items with uncertainty {result.uncertainty}") +else: + print(f"Parsing failed: {result.error_message}") +``` + +### 10.2 Integrating with a custom Model call + +If you are using an OpenHosta `Model` instance directly: + +```python +from OpenHosta.defaults import config +from OpenHosta.guarded.resolver import TypeResolver + +model = config.DefaultModel +messages = [{"role": "user", "content": "Return the price of BTC as a float."}] + +# Call the LLM +response_dict = model.api_call(messages) +raw_content = model.get_response_content(response_dict) + +# Parse directly +price = TypeResolver.resolve(float).attempt(raw_content).data + +print(f"Current price: {price}") +``` + +### 10.3 Why use `attempt()`? + +Using `attempt()` instead of direct instantiation (e.g., `GuardedInt(val)`) gives you more control: + +1. **No Exceptions**: It returns a `CastingResult` instead of raising `ValueError`, making it safer for production pipelines. +2. **Metadata**: You get the `uncertainty` and `abstraction_level` (native vs heuristic). +3. **Configurable Tolerance**: You can specify how "creative" the parser should be. + +```python +from OpenHosta.guarded import GuardedInt, Tolerance + +result = GuardedInt.attempt("42 # with comments", tolerance=Tolerance.STRICT) +# result.success will be False (STRICT only accepts clean "42") + +result = GuardedInt.attempt("42 # with comments", tolerance=Tolerance.FLEXIBLE) +# result.success will be True (FLEXIBLE accepts heuristic cleaning) +``` diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index abdadf79..eafdb72b 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -64,6 +64,7 @@ 'john.doe@company.com' """ +import re from abc import ABC, ABCMeta from typing import Any, Tuple, ClassVar, Dict, Optional, Literal from dataclasses import dataclass, is_dataclass, fields @@ -316,6 +317,84 @@ def unwrap(self): return self._recursive_unwrap(self) + @classmethod + def _clean_llm_response(cls, value: str) -> str: + """ + Nettoie une réponse brute du LLM pour extraire la valeur utile. + - Extrait le contenu des blocs de code Markdown (```python ... ```) + - Supprime les commentaires Python (# ...) + - Gère les commentaires non-standard (ex: * ...) + - Supprime les explications textuelles avant/après l'expression + """ + if not isinstance(value, str): + return value + + cleaned = value.strip() + + # 1. Extraction des blocs de code Markdown + # On cherche le premier bloc ```python ou ``` et on prend son contenu + code_block_match = re.search(r"```(?:python)?\n?(.*?)\n?```", cleaned, re.DOTALL) + if code_block_match: + cleaned = code_block_match.group(1).strip() + else: + # 1.b Heuristique pour le bruit avant l'expression (ex: "Le résultat est : 42") + # Si on n'a pas de bloc Markdown, on regarde s'il y a un séparateur ':' + if ":" in cleaned and not (cleaned.startswith("{") or cleaned.startswith("[")): + # On split au premier ':' + parts = cleaned.split(":", 1) + prefix = parts[0].strip() + potential = parts[1].strip() + + # Si la partie après ':' commence par un caractère d'expression typique, on la garde + is_expr = potential and (potential[0] in "[{('\"-0123456789" or + potential.lower().startswith(("true", "false", "none")) or + re.match(r"^[A-Z][a-zA-Z0-9_]*\(", potential)) + if is_expr: + cleaned = potential + + # 2. Heuristique pour les explications après l'expression + # On fait ça AVANT de supprimer les lignes vides, car on s'appuie sur \n\n + if "\n\n" in cleaned: + # On ne coupe que si la première partie semble être une expression complète + potential_expr = cleaned.split("\n\n")[0].strip() + + # Heuristiques de complétude : + # 1. Scalaire simple (int, float, bool, None, complex) + is_simple_val = potential_expr.isnumeric() or \ + potential_expr.replace(".","").replace("+","").replace("-","").replace("j","").isnumeric() or \ + potential_expr.lower() in ("true", "false", "none") + # 2. Chaîne quotée + is_quoted = (potential_expr.startswith("'") and potential_expr.endswith("'")) or \ + (potential_expr.startswith('"') and potential_expr.endswith('"')) + # 3. Nom qualifié (Enum) + is_qualified = re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)+", potential_expr) + # 4. Parenthèses/Crochets/Accolades équilibrés + is_balanced = (potential_expr.endswith(")") and potential_expr.count("(") == potential_expr.count(")")) or \ + (potential_expr.endswith("]") and potential_expr.count("[") == potential_expr.count("]")) or \ + (potential_expr.endswith("}") and potential_expr.count("{") == potential_expr.count("}")) + + if is_simple_val or is_quoted or is_qualified or is_balanced: + cleaned = potential_expr + + # 3. Suppression des commentaires ligne par ligne + lines = cleaned.split("\n") + new_lines = [] + for line in lines: + # Supprimer tout ce qui suit # + line = line.split("#")[0] + # Supprimer tout ce qui suit * s'il est précédé d'un espace ou en début de ligne + if " *" in line: + line = line.split(" *")[0] + elif line.strip().startswith("*"): + line = "" + + if line.strip(): + new_lines.append(line.rstrip()) + + cleaned = "\n".join(new_lines).strip() + + return cleaned + @classmethod def attempt(cls, value: Any, tolerance: ToleranceLevel|None = None) -> CastingResult: """ diff --git a/src/OpenHosta/guarded/subclassableclasses.py b/src/OpenHosta/guarded/subclassableclasses.py index 5b82a762..d2ce5923 100644 --- a/src/OpenHosta/guarded/subclassableclasses.py +++ b/src/OpenHosta/guarded/subclassableclasses.py @@ -137,8 +137,7 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: """Recherche case-insensitive par nom ou par valeur.""" - value = str(value) - cleaned_val = value.strip() + cleaned_val = cls._clean_llm_response(value) if cleaned_val.startswith("<") and cleaned_val.endswith(">"): cleaned_val = cleaned_val[1:-1].strip() diff --git a/src/OpenHosta/guarded/subclassablecollections.py b/src/OpenHosta/guarded/subclassablecollections.py index cebac9de..e2b27c6f 100644 --- a/src/OpenHosta/guarded/subclassablecollections.py +++ b/src/OpenHosta/guarded/subclassablecollections.py @@ -147,7 +147,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Accepter les strings représentant des listes elif isinstance(value, str): - value_s = value.strip() + value_s = cls._clean_llm_response(value) # Format "[1, 2, 3]" if value_s.startswith('[') and value_s.endswith(']'): @@ -248,7 +248,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Accepter les strings représentant des sets elif isinstance(value, str): - value_s = value.strip() + value_s = cls._clean_llm_response(value) # Format "{1, 2, 3}" ou "frozenset({...})" ou "frozenset([...])" if value_s.startswith("frozenset(") and value_s.endswith(")"): @@ -376,7 +376,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s items = None # Accepter les strings représentant des dicts if isinstance(value, str): - value_s = value.strip() + value_s = cls._clean_llm_response(value) # Format JSON if value_s.startswith('{') and value_s.endswith('}'): @@ -512,7 +512,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Accepter les strings représentant des tuples elif isinstance(value, str): - value_s = value.strip("\n \t") + value_s = cls._clean_llm_response(value) # Format "(1, 2, 3)" if value_s.startswith('(') and value_s.endswith(')'): @@ -743,7 +743,7 @@ def _parse_heuristic(cls, value: Any): # We try to make it as a string if it's not already a string, to let the LLM try to parse it as a constructor call or dict - v_strip = str(value).strip().replace("\n", "") + v_strip = cls._clean_llm_response(value) # Obtenir le nom de la classe d'origine (sans le préfixe Guarded_) original_name = cls._type_py.__name__ if hasattr(cls, '_type_py') else cls.__name__ diff --git a/src/OpenHosta/guarded/subclassableliterals.py b/src/OpenHosta/guarded/subclassableliterals.py index d4fd58ad..2b41da65 100644 --- a/src/OpenHosta/guarded/subclassableliterals.py +++ b/src/OpenHosta/guarded/subclassableliterals.py @@ -77,9 +77,10 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] @classmethod def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: """Tentative de conversion avec nettoyage basique.""" + cleaned = cls._clean_llm_response(value) + # Pour les strings, essayer avec strip() et case-insensitive - if isinstance(value, str) and all(isinstance(v, str) for v in cls._allowed_values): - cleaned = value.strip() + if isinstance(cleaned, str) and all(isinstance(v, str) for v in cls._allowed_values): # Remove quotes if present if (cleaned.startswith('"') and cleaned.endswith('"')) or \ (cleaned.startswith("'") and cleaned.endswith("'")): @@ -95,11 +96,11 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s return UncertaintyLevel(Tolerance.PRECISE), allowed, None # Pour les nombres, essayer de convertir - if isinstance(value, str) and any(isinstance(v, (int, float)) for v in cls._allowed_values): + if isinstance(cleaned, str) and any(isinstance(v, (int, float)) for v in cls._allowed_values): try: # Essayer int try: - int_val = int(value) + int_val = int(cleaned) if int_val in cls._allowed_values: return UncertaintyLevel(Tolerance.PRECISE), int_val, None except ValueError: @@ -107,7 +108,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Essayer float try: - float_val = float(value) + float_val = float(cleaned) if float_val in cls._allowed_values: return UncertaintyLevel(Tolerance.PRECISE), float_val, None except ValueError: diff --git a/src/OpenHosta/guarded/subclassablepydantic.py b/src/OpenHosta/guarded/subclassablepydantic.py index 4b11fb3a..2d717d4a 100644 --- a/src/OpenHosta/guarded/subclassablepydantic.py +++ b/src/OpenHosta/guarded/subclassablepydantic.py @@ -129,7 +129,7 @@ def _parse_heuristic(cls, value: Any): if isinstance(value, dict): data_dict = value elif isinstance(value, str): - v_strip = value.strip().strip("\"'").replace("\n", "") + v_strip = cls._clean_llm_response(value) if v_strip.startswith(model_cls.__name__ + "(") or v_strip.startswith("{"): import ast try: diff --git a/src/OpenHosta/guarded/subclassablescalars.py b/src/OpenHosta/guarded/subclassablescalars.py index 10c83480..377dd884 100644 --- a/src/OpenHosta/guarded/subclassablescalars.py +++ b/src/OpenHosta/guarded/subclassablescalars.py @@ -28,8 +28,10 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] return UncertaintyLevel(Tolerance.STRICT), int(value), None # Cas : String numérique propre "123" - if isinstance(value, str) and value.isnumeric(): - return UncertaintyLevel(Tolerance.STRICT), int(value), None + if isinstance(value, str): + v_strip = value.strip() + if v_strip.isnumeric(): + return UncertaintyLevel(Tolerance.STRICT), int(v_strip), None # Cas : Float rond (42.0) -> Accepté comme int if isinstance(value, float) and value.is_integer(): @@ -42,10 +44,9 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: if isinstance(value, bool): return UncertaintyLevel(Tolerance.FLEXIBLE), int(value), None - value = str(value) - + value = cls._clean_llm_response(value) # Nettoyage : espaces, et devises courantes - value = value.strip().replace(" ", "") + value = value.replace(" ", "") # Gestion des séparateurs de milliers (ex: 1,000 -> 1000) # On enlève les virgules si la chaîne contient uniquement des chiffres et des virgules @@ -55,7 +56,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s # Gestion des nombres négatifs et validation finale # Regex: Optionnel '-', suivi de chiffres if re.fullmatch(r'-?\d+', value): - return UncertaintyLevel(Tolerance.FLEXIBLE), int(value), None + return UncertaintyLevel(Tolerance.PRECISE), int(value), None return UncertaintyLevel(Tolerance.ANYTHING), value, None @@ -88,9 +89,8 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] @classmethod def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: - value = str(value) - - value = value.strip().replace(" ", "") + value = cls._clean_llm_response(value) + value = value.replace(" ", "") # Standardisation : Remplacer ',' par '.' (Format européen) value = value.replace(",", ".") @@ -99,7 +99,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s value = ''.join(slices[:-1])+ "." + slices[-1] try: - return UncertaintyLevel(Tolerance.FLEXIBLE), float(value), None + return UncertaintyLevel(Tolerance.PRECISE), float(value), None except ValueError as e: return UncertaintyLevel(Tolerance.ANYTHING), value, str(e) @@ -118,13 +118,14 @@ class GuardedUtf8(GuardedPrimitive, str): def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: if isinstance(value, str): + # If the string has leading/trailing whitespace, starts with quotes, + # contains markdown blocks or comments, let heuristic handle it for better cleaning + if (value.strip() != value or + value.startswith("'") or value.startswith('"') or + "```" in value or "#" in value or "\n" in value): + return UncertaintyLevel(Tolerance.ANYTHING), value, "String needs cleaning" - if value.startswith("'") or value.startswith('"'): - # Remove quotes if they are present at the beginning and end of the string - # This is a common pattern in JSON and other format - return UncertaintyLevel(Tolerance.FLEXIBLE), str(value.strip("\"'")), None - else: - return UncertaintyLevel(Tolerance.STRICT), str(value), None + return UncertaintyLevel(Tolerance.STRICT), str(value), None return UncertaintyLevel(Tolerance.ANYTHING), value, None @@ -136,6 +137,13 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s return UncertaintyLevel(Tolerance.STRICT), value.decode("utf-8"), None except UnicodeDecodeError as e: return UncertaintyLevel(Tolerance.ANYTHING), value, f"{e}" + + if isinstance(value, str): + cleaned = cls._clean_llm_response(value) + # Remove quotes if they wrap the whole thing + if (cleaned.startswith("'") and cleaned.endswith("'")) or (cleaned.startswith('"') and cleaned.endswith('"')): + cleaned = cleaned[1:-1] + return UncertaintyLevel(Tolerance.PRECISE), cleaned, None return UncertaintyLevel(Tolerance.ANYTHING), value, f"Expected str or bytes, got {type(value)}" @@ -159,8 +167,8 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] @classmethod def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: try: - value = str(value).replace(" ", "") - return UncertaintyLevel(Tolerance.TYPE_COMPLIANT), complex(value), None + value = cls._clean_llm_response(value).replace(" ", "") + return UncertaintyLevel(Tolerance.PRECISE), complex(value), None except (ValueError, TypeError) as e: return UncertaintyLevel(Tolerance.ANYTHING), value, str(e) diff --git a/src/OpenHosta/guarded/subclassablewithproxy.py b/src/OpenHosta/guarded/subclassablewithproxy.py index 7130c196..7a0a9fa6 100644 --- a/src/OpenHosta/guarded/subclassablewithproxy.py +++ b/src/OpenHosta/guarded/subclassablewithproxy.py @@ -29,20 +29,20 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] @classmethod def _parse_heuristic(cls, value) -> Tuple[UncertaintyLevel, Any, Optional[str]]: - value = str(value) + cleaned = cls._clean_llm_response(value) - value = value.strip(" \n\"\'") - if value == 'None': + cleaned = cleaned.strip(" \"\'") + if cleaned == 'None': return UncertaintyLevel(Tolerance.FLEXIBLE), None, None - value = value.lower() - if value == "none": + cleaned = cleaned.lower() + if cleaned == "none": return UncertaintyLevel(Tolerance.FLEXIBLE), None, None - if value in cls._type_knowledge["PROG"]: + if cleaned in cls._type_knowledge["PROG"]: return UncertaintyLevel(Tolerance.CREATIVE), None, None - return UncertaintyLevel(Tolerance.ANYTHING), value, None + return UncertaintyLevel(Tolerance.ANYTHING), cleaned, None @classmethod def _parse_semantic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: @@ -119,8 +119,8 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s if isinstance(value, (int, float, bool)): return UncertaintyLevel(Tolerance.STRICT), bool(value), None - if isinstance(value, str): - v = value.strip().lower() + if isinstance(value, str) or value is not None: + v = cls._clean_llm_response(value).lower() if v in cls._type_knowledge[True]: return UncertaintyLevel(Tolerance.STRICT), True, None if v in cls._type_knowledge[False]: @@ -160,12 +160,12 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] @classmethod def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str]]: - if isinstance(value, str): - value = value.strip() + if isinstance(value, str) or value is not None: + cleaned = cls._clean_llm_response(value) # range(start, stop[, step]) - if value.startswith("range(") and value.endswith(")"): + if cleaned.startswith("range(") and cleaned.endswith(")"): try: - content = value[6:-1] + content = cleaned[6:-1] parts = [int(x.strip()) for x in content.split(",") if x.strip()] return UncertaintyLevel(Tolerance.PRECISE), range(*parts), None except Exception: diff --git a/tests/guarded/test_attempt_borderlines.py b/tests/guarded/test_attempt_borderlines.py new file mode 100644 index 00000000..36e280db --- /dev/null +++ b/tests/guarded/test_attempt_borderlines.py @@ -0,0 +1,187 @@ +import pytest +from typing import List, Dict, Set, Tuple, Optional, Union, Literal +from dataclasses import dataclass +from enum import Enum +from pydantic import BaseModel +from OpenHosta.guarded.resolver import TypeResolver + +@dataclass +class Person: + name: str + age: int + +class Color(Enum): + RED = "red" + GREEN = "green" + +class Item(BaseModel): + id: int + name: str + +def format_scenario(scenario: str, value: str) -> str: + if scenario == "direct_oneline": + return value + if scenario == "direct_multiline": + return f"\n{value}\n" + if scenario == "python_block_comments": + return f"```python\n# Result follows\n{value} # The actual result\n```" + if scenario == "answer_blank_explanation": + return f"{value}\n\nThis is the explanation of why the result is what it is." + if scenario == "python_block_surrounding": + return f"Here is the result you asked for:\n```python\n{value}\n```\nHope this helps!" + if scenario == "multiline_structured_comments": + # Simulate multiline with comments on each line + lines = value.split("\n") + return "\n".join(f"{line} # comment" for line in lines) + if scenario == "broken_python_star_comments": + # Simulate non-standard comments and extra text at the end + return f"{value} * this is a non-standard comment\n* end of page comment" + return value + +SCENARIOS = [ + "direct_oneline", + "direct_multiline", + "python_block_comments", + "answer_blank_explanation", + "python_block_surrounding", + "multiline_structured_comments", + "broken_python_star_comments", +] + +class TestScalarBorderlines: + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_int(self, scenario): + guarded_type = TypeResolver.resolve(int) + raw = format_scenario(scenario, "42") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == 42 + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_float(self, scenario): + guarded_type = TypeResolver.resolve(float) + raw = format_scenario(scenario, "3.14") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert abs(result.data - 3.14) < 1e-6 + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_bool(self, scenario): + guarded_type = TypeResolver.resolve(bool) + raw = format_scenario(scenario, "True") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data is True + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_complex(self, scenario): + guarded_type = TypeResolver.resolve(complex) + raw = format_scenario(scenario, "1+2j") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == 1+2j + +class TestCollectionBorderlines: + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_list_int(self, scenario): + guarded_type = TypeResolver.resolve(List[int]) + raw = format_scenario(scenario, "[1, 2, 3]") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == [1, 2, 3] + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_dict_str_int(self, scenario): + guarded_type = TypeResolver.resolve(Dict[str, int]) + raw = format_scenario(scenario, '{"a": 1, "b": 2}') + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == {"a": 1, "b": 2} + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_set_str(self, scenario): + guarded_type = TypeResolver.resolve(Set[str]) + raw = format_scenario(scenario, "{'a', 'b'}") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == {"a", "b"} + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_tuple_int_str(self, scenario): + guarded_type = TypeResolver.resolve(Tuple[int, str]) + raw = format_scenario(scenario, "(1, 'a')") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == (1, 'a') + +class TestStructuredBorderlines: + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_dataclass(self, scenario): + guarded_type = TypeResolver.resolve(Person) + raw_val = "Person(name='George', age=67)" + raw = format_scenario(scenario, raw_val) + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data.name == "George" + assert result.data.age == 67 + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_pydantic(self, scenario): + guarded_type = TypeResolver.resolve(Item) + raw_val = "Item(id=1, name='Laptop')" + raw = format_scenario(scenario, raw_val) + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data.id == 1 + assert result.data.name == "Laptop" + +class TestAdvancedBorderlines: + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_optional_int(self, scenario): + guarded_type = TypeResolver.resolve(Optional[int]) + # Test with value + raw = format_scenario(scenario, "42") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario} (value): {result.error_message}" + assert result.data == 42 + + # Test with None + raw_none = format_scenario(scenario, "None") + result_none = guarded_type.attempt(raw_none) + assert result_none.success, f"Failed for scenario {scenario} (None): {result_none.error_message}" + assert result_none.data is None + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_union_int_str(self, scenario): + guarded_type = TypeResolver.resolve(Union[int, str]) + # Should favor int if possible + raw = format_scenario(scenario, "42") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario} (int): {result.error_message}" + assert result.data == 42 + + # Fallback to str + raw_str = format_scenario(scenario, "'hello'") + result_str = guarded_type.attempt(raw_str) + assert result_str.success, f"Failed for scenario {scenario} (str): {result_str.error_message}" + assert result_str.data == "hello" + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_literal(self, scenario): + guarded_type = TypeResolver.resolve(Literal["A", "B"]) + raw = format_scenario(scenario, "'A'") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == "A" + + @pytest.mark.parametrize("scenario", SCENARIOS) + def test_enum(self, scenario): + guarded_type = TypeResolver.resolve(Color) + raw = format_scenario(scenario, "Color.RED") + result = guarded_type.attempt(raw) + assert result.success, f"Failed for scenario {scenario}: {result.error_message}" + assert result.data == Color.RED diff --git a/tests/guarded/test_direct_parsing.py b/tests/guarded/test_direct_parsing.py new file mode 100644 index 00000000..aadead5f --- /dev/null +++ b/tests/guarded/test_direct_parsing.py @@ -0,0 +1,98 @@ +import pytest +from typing import List +from OpenHosta.guarded.resolver import TypeResolver +from OpenHosta.guarded import GuardedInt, Tolerance + +def test_doc_example_10_1(): + """Verify example 10.1 from documentation: List parsing with prefix noise.""" + MyType = List[int] + # This is exactly what the user tried + raw_output = "I found these numbers: [10, 20, 30] # and some noise" + + guarded_type = TypeResolver.resolve(MyType) + result = guarded_type.attempt(raw_output) + + assert result.success, f"Should parse list even with prefix noise. Error: {result.error_message}" + assert result.data == [10, 20, 30] + +def test_doc_example_10_3_strict(): + """Verify example 10.3 from documentation: STRICT tolerance.""" + # STRICT should reject noise + result = GuardedInt.attempt("42 # with comments", tolerance=Tolerance.STRICT) + assert not result.success + +def test_doc_example_10_3_flexible(): + """Verify example 10.3 from documentation: FLEXIBLE tolerance.""" + # FLEXIBLE should accept comments + result = GuardedInt.attempt("42 # with comments", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == 42 + +def test_prefix_noise_scalar(): + """Verify scalar parsing with prefix noise.""" + result = GuardedInt.attempt("The age is: 42", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == 42 + +def test_prefix_noise_complex(): + """Verify complex parsing with prefix noise.""" + from OpenHosta.guarded import GuardedComplex + result = GuardedComplex.attempt("Result: 1+2j", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == 1+2j + +def test_prefix_noise_optional(): + """Verify optional parsing with prefix noise.""" + from typing import Optional + guarded_type = TypeResolver.resolve(Optional[int]) + result = guarded_type.attempt("The value is: 42", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == 42 + + result_none = guarded_type.attempt("The value is: None", tolerance=Tolerance.FLEXIBLE) + assert result_none.success + assert result_none.data is None + +def test_prefix_noise_dict(): + """Verify dict parsing with prefix noise.""" + from typing import Dict + guarded_type = TypeResolver.resolve(Dict[str, int]) + result = guarded_type.attempt("Here is the dict: {'a': 1, 'b': 2}", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == {'a': 1, 'b': 2} + +def test_prefix_noise_dataclass(): + """Verify dataclass parsing with prefix noise.""" + from OpenHosta.guarded import guarded_dataclass + + @guarded_dataclass + class Person: + name: str + age: int + + guarded_type = TypeResolver.resolve(Person) + result = guarded_type.attempt("Created person: Person(name='Bob', age=30)", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data.name == "Bob" + assert result.data.age == 30 + +def test_prefix_noise_pydantic(): + """Verify pydantic parsing with prefix noise.""" + from pydantic import BaseModel + class User(BaseModel): + id: int + username: str + + guarded_type = TypeResolver.resolve(User) + result = guarded_type.attempt("User data: {'id': 123, 'username': 'alice'}", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data.id == 123 + assert result.data.username == 'alice' + +def test_prefix_noise_literal(): + """Verify literal parsing with prefix noise.""" + from typing import Literal + guarded_type = TypeResolver.resolve(Literal["A", "B"]) + result = guarded_type.attempt("Option chosen: 'A'", tolerance=Tolerance.FLEXIBLE) + assert result.success + assert result.data == "A" From c08220d20e3aef410aa232b9a6703cba491644ce Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Sat, 25 Apr 2026 11:14:58 +0200 Subject: [PATCH 27/33] feat: implement inspection support for Guarded types and add debugging utilities for prompt and state monitoring --- docs/debugging.md | 85 ++++++++++++++ docs/index.md | 3 + src/OpenHosta/__init__.py | 6 + src/OpenHosta/core/analizer.py | 1 + src/OpenHosta/core/logger.py | 122 ++++++++++++++------- src/OpenHosta/guarded/primitives.py | 14 ++- src/OpenHosta/guarded/resolver.py | 5 +- src/OpenHosta/pipelines/simple_pipeline.py | 11 +- tests/functionnal/test_inspection.py | 112 +++++++++++++++++++ tests/manual/test_inspection.py | 38 +++++++ tests/manual/test_inspection_stream.py | 30 +++++ 11 files changed, 383 insertions(+), 44 deletions(-) create mode 100644 docs/debugging.md create mode 100644 tests/functionnal/test_inspection.py create mode 100644 tests/manual/test_inspection.py create mode 100644 tests/manual/test_inspection_stream.py diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 00000000..44774af1 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,85 @@ +# 🛠️ Debugging and Inspection + +OpenHosta provides powerful tools to inspect how your LLM functions behave and to verify the quality of the generated data. + +## I. Using Guarded Types for Introspection + +By default, `emulate()` returns native Python types (int, str, list, etc.). While convenient, these types lose the "provenance" metadata (the prompt that produced them, the raw LLM response, etc.). + +To keep this metadata, use the `Guarded` wrapper or a specific `Guarded` type in your annotation: + +```python +from OpenHosta import emulate, Guarded + +# Native return (no metadata) +def get_age(name: str) -> int: + return emulate() + +# Guarded return (metadata preserved) +def get_age_guarded(name: str) -> Guarded[int]: + return emulate() +``` + +### Why use Guarded types? +1. **Traceability**: You can see the exact conversation that produced the value. +2. **Uncertainty**: You can check if the LLM was "confident" about the result. +3. **Stability**: You can get a "clean" version of the data without LLM artifacts. + +--- + +## II. Inspection Functions + +Once you have a Guarded value, you can use the following functions: + +### 1. `conversation(value)` +Prints the full conversation (System prompt + User prompt) that led to this value. + +```python +age = get_age_guarded("John") +conversation(age) +``` + +### 2. `readable(value)` +Returns a human-friendly string representation of the value. +- It removes LLM artifacts (comments, extra text). +- It pretty-prints complex structures (lists, dicts). +- It is **stable**: `GuardedType(readable(val))` is guaranteed to produce the same value as the original. + +### 3. `markdown(value)` +Same as `readable()`, but wrapped in Markdown code blocks for better rendering in reports or other LLMs. + +--- + +## III. Comparison Table + +| Feature | `str(val)` | `repr(val)` | `readable(val)` | `markdown(val)` | +| :--- | :--- | :--- | :--- | :--- | +| **Target** | Programmatic | Developers | Humans / Logs | Reports / LLMs | +| **Quotes** | No (for str) | Yes (for str) | No | Block if complex | +| **Format** | Default Python | Technical | Pretty-printed | Markdown Block | +| **Stability** | Yes | Yes | **Guaranteed** | Yes | + +--- + +## IV. Debugging Workflows + +### Inspecting a Function (Standard) +If you don't want to change your return types, you can always inspect the *function* itself to see its *last* execution: + +```python +from OpenHosta import print_last_prompt + +age = get_age("John") +print_last_prompt(get_age) +``` + +### Inspecting a Value (Recommended for Loops/Batch) +If you are calling a function many times, `print_last_prompt(func)` only shows the last one. Using `Guarded` types allows you to inspect *any* result at any time: + +```python +results = [get_age_guarded(n) for n in names] + +# Later, inspect a specific result +conversation(results[5]) +print(f"Readable value: {readable(results[5])}") +``` diff --git a/docs/index.md b/docs/index.md index 7712d4f5..ff91462b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,6 +32,9 @@ Simplify concurrent execution of `emulate_async` with the `gather_data` batching ### 📐 [Guarded Types](guarded.md) Deep dive into OpenHosta's type validation and conversion system with configurable tolerance. +### 🛠️ [Debugging & Inspection](debugging.md) +Learn how to use `conversation()`, `readable()`, and `markdown()` to inspect LLM outputs and debug your semantic code. + ### 🛡️ [Production & Auditing](production.md) Learn how to deploy OpenHosta at scale, enable audit logging for compliance, and track token usage in production. diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index bd5b63b1..ff0162d5 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -4,6 +4,7 @@ from .defaults import reload_dotenv from .core.logger import print_last_prompt, print_last_decoding +from .core.logger import conversation, readable, markdown from .core.logger import print_last_probability_distribution, print_last_uncertainty from .core.meta_prompt import MetaPrompt from .core.uncertainty import safe @@ -18,6 +19,7 @@ # from .semantics import SemanticSet, SemanticDict # Maybe in 5.0 from .semantics.operators import test, test_async +from .guarded.primitives import Guarded from .models import OpenAICompatibleModel as Model from .models import OpenAICompatibleModel @@ -51,6 +53,9 @@ "MetaPrompt", "print_last_prompt", "print_last_decoding", + "conversation", + "readable", + "markdown", "print_last_probability_distribution", "print_last_uncertainty", "Pipeline", @@ -60,6 +65,7 @@ "test_async", "UncertaintyError", "track_costs", + "Guarded", "register_audit_callback", "unregister_audit_callback", ) diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index 58a26edc..bebba9b6 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -147,6 +147,7 @@ def nice_type_name(p_type) -> str: t=t.replace("typing.", "") t=t.replace("collections.abc.", "") t=t.replace("builtins.", "") + t=t.replace("OpenHosta.guarded.primitives.", "") return t if hasattr(p_type, "__name__"): diff --git a/src/OpenHosta/core/logger.py b/src/OpenHosta/core/logger.py index af6f3604..73eff04a 100644 --- a/src/OpenHosta/core/logger.py +++ b/src/OpenHosta/core/logger.py @@ -1,68 +1,110 @@ -from typing import Callable, cast -from .inspection import HostaInjectedFunction +from typing import Callable, cast, Any, Optional, Union +from .inspection import HostaInjectedFunction, Inspection import platform +import json IS_UNIX = platform.system() != "Windows" -def print_last_prompt(function_pointer:Callable): +def _get_inspection(target: Any) -> Optional[Inspection]: + """Helper to extract Inspection from either a function or a Guarded value.""" + if hasattr(target, "hosta_inspection"): + return target.hosta_inspection + if hasattr(target, "_hosta_inspection"): + return target._hosta_inspection + return None + + +def conversation(target: Any): + """Alias for print_last_prompt.""" + print_last_prompt(target) + + +def readable(target: Any) -> str: """ - Print the last prompt sent to the LLM when using function `function_pointer`. + Returns a clean, stable string representation of a Guarded value. + Guaranteed to be parseable back into the same type. """ - function_pointer = cast(HostaInjectedFunction, function_pointer) - if hasattr(function_pointer, "hosta_inspection") and function_pointer.hosta_inspection.model is not None: - - print("Model\n-----------------\nname="+function_pointer.hosta_inspection.model.model_name+"\nbasse_url="+function_pointer.hosta_inspection.model.base_url+"\n") - function_pointer.hosta_inspection.model.print_last_prompt(function_pointer.hosta_inspection) + from ..guarded.primitives import GuardedPrimitive, ProxyWrapper - if "enum_normalized_probs" in function_pointer.hosta_inspection.logs: - + val = target + if isinstance(target, (GuardedPrimitive, ProxyWrapper)): + val = target.unwrap() + + if isinstance(val, (list, dict, set, tuple)): + try: + return json.dumps(val, indent=2, ensure_ascii=False) + except (TypeError, ValueError): + return str(val) + + return str(val) + + +def markdown(target: Any) -> str: + """ + Returns a markdown-formatted version of the value. + """ + text = readable(target) + if "\n" in text or text.startswith(("[", "{")): + return f"```python\n{text}\n```" + return text + + +def print_last_prompt(target: Union[Callable, Any]): + """ + Print the last prompt sent to the LLM when using function or value `target`. + """ + inspection = _get_inspection(target) + + if inspection and inspection.model is not None: + print(f"Model\n-----------------\nname={inspection.model.model_name}\nbase_url={inspection.model.base_url}\n") + inspection.model.print_last_prompt(inspection) + + if "enum_normalized_probs" in inspection.logs: print("-----------------") - print_last_uncertainty(function_pointer) + print_last_uncertainty(target) else: - print("No prompt found for this function.") + print("No prompt found for this target.") -def print_last_decoding(function_pointer:Callable): +def print_last_decoding(target: Union[Callable, Any]): """ - Print the steps of the last decoding when using function `function_pointer`. + Print the steps of the last decoding when using function or value `target`. """ - function_pointer = cast(HostaInjectedFunction, function_pointer) + inspection = _get_inspection(target) - if hasattr(function_pointer, "hosta_inspection") and function_pointer.hosta_inspection.model is not None: - function_pointer.hosta_inspection.pipeline.print_last_decoding(function_pointer.hosta_inspection) + if inspection and inspection.model is not None: + inspection.pipeline.print_last_decoding(inspection) else: - print("No prompt found for this function.") + print("No decoding logs found for this target.") -def print_last_probability_distribution(function_pointer:Callable): + +def print_last_probability_distribution(target: Union[Callable, Any]): """ - Print the last probability distribution log when using function `function_pointer`. + Print the last probability distribution log when using function or value `target`. """ - function_pointer = cast(HostaInjectedFunction, function_pointer) - if hasattr(function_pointer, "hosta_inspection") and \ - "enum_normalized_probs" in function_pointer.hosta_inspection.logs: - nomalized_probs = function_pointer.hosta_inspection.logs["enum_normalized_probs"] - - for k,v in nomalized_probs.items(): + inspection = _get_inspection(target) + if inspection and "enum_normalized_probs" in inspection.logs: + nomalized_probs = inspection.logs["enum_normalized_probs"] + for k, v in nomalized_probs.items(): print(f"Value: {k:<10}, Probability: {v:.4f}") -def print_last_uncertainty(function_pointer:Callable): + +def print_last_uncertainty(target: Union[Callable, Any]): """ - Print the last uncertainty log when using function `function_pointer`. - """ - function_pointer = cast(HostaInjectedFunction, function_pointer) - if hasattr(function_pointer, "hosta_inspection") and \ - "enum_normalized_probs" in function_pointer.hosta_inspection.logs: - nomalized_probs = function_pointer.hosta_inspection.logs["enum_normalized_probs"] - - for k,v in nomalized_probs.items(): + Print the last uncertainty log when using function or value `target`. + """ + inspection = _get_inspection(target) + if inspection and "enum_normalized_probs" in inspection.logs: + nomalized_probs = inspection.logs["enum_normalized_probs"] + + for k, v in nomalized_probs.items(): print(f"Value: {k:<10}, Certainty: {v}") - if hasattr(function_pointer, "hosta_inspection") and \ - "uncertainty_counters" in function_pointer.hosta_inspection.logs: - counters = function_pointer.hosta_inspection.logs["uncertainty_counters"] + if "uncertainty_counters" in inspection.logs: + counters = inspection.logs["uncertainty_counters"] print("------") - for k,v in counters.items(): + for k, v in counters.items(): print(f"{k:<20}: {v}") else: diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index eafdb72b..45aa205d 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -66,7 +66,7 @@ import re from abc import ABC, ABCMeta -from typing import Any, Tuple, ClassVar, Dict, Optional, Literal +from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeVar, Generic from dataclasses import dataclass, is_dataclass, fields @@ -77,6 +77,15 @@ AbstractionLevel = Literal["native", "heuristic", "semantic", "knowledge", "failed"] UncertaintyLevel = float +T = TypeVar("T") + +class Guarded(Generic[T]): + """ + Generic marker for explicitly requested Guarded types. + Usage: Guarded[int], Guarded[List[str]], etc. + """ + pass + @dataclass(frozen=True) class GuardedCallInput: """Conteneur pour transporter plusieurs arguments dans le pipeline Guarded.""" @@ -209,6 +218,7 @@ def __new__(cls, *args: Any, **kwargs: Any): instance._uncertainty = result.uncertainty instance._abstraction_level = result.abstraction instance._python_value = result.data + instance._hosta_inspection = None return instance @@ -569,6 +579,8 @@ class GuardedBool(GuardedPrimitive, ProxyWrapper): ... """ + _hosta_inspection: Optional[Any] = None + def unwrap(self): """Retourne la valeur Python native avec unwrapping récursif.""" return GuardedPrimitive._recursive_unwrap(getattr(self, "_python_value", None)) diff --git a/src/OpenHosta/guarded/resolver.py b/src/OpenHosta/guarded/resolver.py index 4cfcbd0b..cef0c599 100644 --- a/src/OpenHosta/guarded/resolver.py +++ b/src/OpenHosta/guarded/resolver.py @@ -24,7 +24,7 @@ def is_typeddict(t): return False from dataclasses import is_dataclass # Imports des primitives OpenHosta -from .primitives import GuardedPrimitive +from .primitives import GuardedPrimitive, Guarded from .subclassablescalars import ( GuardedInt, GuardedUtf8, GuardedFloat, @@ -229,6 +229,9 @@ def _do_resolve(cls, annotation: Any) -> Type[GuardedPrimitive]: origin = get_origin(annotation) args = get_args(annotation) + if origin is Guarded: + return cls.resolve(args[0]) + if origin is not None: # Callable origin check (handles subscripted Callable[[...], ...]) if origin in (Callable, typing.Callable, collections.abc.Callable): diff --git a/src/OpenHosta/pipelines/simple_pipeline.py b/src/OpenHosta/pipelines/simple_pipeline.py index 9c55dc49..d45a5c7f 100644 --- a/src/OpenHosta/pipelines/simple_pipeline.py +++ b/src/OpenHosta/pipelines/simple_pipeline.py @@ -16,7 +16,7 @@ from ..core.audit import trigger_audit_event from ..guarded.resolver import type_returned_data -from ..guarded.primitives import GuardedPrimitive +from ..guarded.primitives import GuardedPrimitive, Guarded, ProxyWrapper MetaDialog = List[Tuple[str, MetaPrompt]] @@ -405,9 +405,16 @@ def pull_type_data_section(self, inspection:Inspection, response:Any) -> Any: # EXCEPT if the user explicitly requested a GuardedType via annotation if hasattr(l_ret_data, "unwrap"): requested_type = inspection.analyse.type - is_guarded_type = isinstance(requested_type, type) and issubclass(requested_type, GuardedPrimitive) + from typing import get_origin + is_guarded_type = (isinstance(requested_type, type) and issubclass(requested_type, GuardedPrimitive)) or \ + (get_origin(requested_type) is Guarded) + if not is_guarded_type: l_ret_data = l_ret_data.unwrap() + + # Inject provenance metadata if it's a Guarded value + if isinstance(l_ret_data, (GuardedPrimitive, ProxyWrapper)): + l_ret_data._hosta_inspection = inspection inspection.logs["response_data"] = l_ret_data diff --git a/tests/functionnal/test_inspection.py b/tests/functionnal/test_inspection.py new file mode 100644 index 00000000..f2af291d --- /dev/null +++ b/tests/functionnal/test_inspection.py @@ -0,0 +1,112 @@ +import pytest +from OpenHosta import emulate, emulate_async, Guarded, conversation, readable, markdown +from OpenHosta.guarded import GuardedInt, GuardedList, GuardedUtf8 +import asyncio + +def test_inspection_guarded_int(capsys): + """Test inspection functions with Guarded[int].""" + def get_age(name: str) -> Guarded[int]: + """Return the age of the person.""" + return emulate() + + age = get_age("John was born in 1990 and we are in 2026") + + # Check type (should be GuardedInt because of automatic resolution) + assert isinstance(age, GuardedInt) + assert int(age) > 0 + + # Test conversation() + conversation(age) + captured = capsys.readouterr() + assert "Model" in captured.out + assert "System prompt" in captured.out + assert "User prompt" in captured.out + assert "name='John was born in 1990 and we are in 2026'" in captured.out + assert "get_age(name)" in captured.out + + # Test readable() + r = readable(age) + assert r == str(int(age)) + + # Test stability + age_copy = GuardedInt(r) + assert age_copy == age + +def test_inspection_guarded_list(capsys): + """Test inspection functions with Guarded[list[int]].""" + def get_scores(name: str) -> Guarded[list[int]]: + """Return a list of 3 scores.""" + return emulate() + + scores = get_scores("John played a very hard game yesterday, he scored 10, then 12, then 15 points") + assert isinstance(scores, GuardedList) + assert len(scores) == 3 + + # Test conversation() + conversation(scores) + captured = capsys.readouterr() + assert "Model" in captured.out + assert "name='John played a very hard game yesterday, he scored 10, then 12, then 15 points'" in captured.out + assert "get_scores(name)" in captured.out + + # Test readable() - should be pretty-printed JSON + r = readable(scores) + assert "[" in r + assert "]" in r + + # Test stability + scores_copy = GuardedList(r) + assert scores_copy == scores + +def test_inspection_markdown(): + """Test markdown formatting.""" + def get_bio(name: str) -> Guarded[str]: + """Return a short bio.""" + return emulate() + + bio = get_bio("John") + m = markdown(bio) + + # Since it's a string, if it has newlines it should be in a block + if "\n" in str(bio): + assert m.startswith("```python") + assert m.endswith("```") + else: + assert m == str(bio) + +@pytest.mark.asyncio +async def test_inspection_async(): + """Test inspection with emulate_async and Guarded[T].""" + async def get_val() -> Guarded[int]: + """Return 42.""" + return await emulate_async() + + val = await get_val() + assert isinstance(val, GuardedInt) + assert val == 42 + + # conversation() should still work + import io + from contextlib import redirect_stdout + f = io.StringIO() + with redirect_stdout(f): + conversation(val) + out = f.getvalue() + assert "Model" in out + +def test_inspection_stability_complex(): + """Verify stability with complex input (artifacts).""" + # Simulate a response with artifacts that gets cleaned + llm_out = "[10, 20, 30] # These are the results" + + # Manual creation to verify the logic we want to document + g_list = GuardedList(llm_out) + assert g_list == [10, 20, 30] + + r = readable(g_list) + # readable() should return just the JSON list, no comments + assert "#" not in r + + # Stability check + g_list_2 = GuardedList(r) + assert g_list_2 == g_list diff --git a/tests/manual/test_inspection.py b/tests/manual/test_inspection.py new file mode 100644 index 00000000..50a0f773 --- /dev/null +++ b/tests/manual/test_inspection.py @@ -0,0 +1,38 @@ + +from OpenHosta import emulate, Guarded, conversation, readable, markdown, print_last_prompt +import os + +# Ensure .env is loaded +from OpenHosta import reload_dotenv +reload_dotenv() + +def test_inspection(): + def get_age(name: str) -> Guarded[int]: + """Return the age of the person.""" + return emulate() + + print("--- Calling get_age('John') ---") + age = get_age("John") + print(f"Result: {age} (type: {type(age)})") + + print("\n--- Testing conversation(age) ---") + conversation(age) + + print("\n--- Testing readable(age) ---") + r = readable(age) + print(f"Readable: {r}") + + print("\n--- Testing markdown(age) ---") + m = markdown(age) + print(f"Markdown:\n{m}") + + print("\n--- Testing stability: GuardedInt(readable(age)) ---") + from OpenHosta.guarded import GuardedInt + age_copy = GuardedInt(readable(age)) + print(f"Copy: {age_copy} (type: {type(age_copy)})") + assert age_copy == age + + print("\n--- SUCCESS ---") + +if __name__ == "__main__": + test_inspection() diff --git a/tests/manual/test_inspection_stream.py b/tests/manual/test_inspection_stream.py new file mode 100644 index 00000000..d3ed89d8 --- /dev/null +++ b/tests/manual/test_inspection_stream.py @@ -0,0 +1,30 @@ + +from OpenHosta import emulate, Guarded, conversation, readable, markdown +from typing import Iterator +import os + +# Ensure .env is loaded +from OpenHosta import reload_dotenv +reload_dotenv() + +def test_inspection_stream(): + def get_ages(names: list[str]) -> Iterator[Guarded[int]]: + """Return the ages of the persons.""" + yield from emulate() + + print("--- Calling get_ages(['John', 'Jane']) ---") + ages = list(get_ages(['John', 'Jane'])) + + for i, age in enumerate(ages): + print(f"\n--- Result {i}: {age} (type: {type(age)}) ---") + + print(f"\n--- conversation(age) for result {i} ---") + conversation(age) + + print(f"\n--- readable(age): {readable(age)} ---") + print(f"\n--- markdown(age):\n{markdown(age)} ---") + + print("\n--- SUCCESS ---") + +if __name__ == "__main__": + test_inspection_stream() From f0393bdbe35e603b75571a0169f3532165adbc19 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Tue, 28 Apr 2026 09:29:33 +0200 Subject: [PATCH 28/33] fix: Guarded[T] shall not be visible in metaprompt Co-authored-by: Copilot --- .gitignore | 3 +- src/OpenHosta/core/analizer.py | 8 +++ src/OpenHosta/guarded/primitives.py | 36 +++++++++-- src/OpenHosta/guarded/subclassableclasses.py | 15 ++++- src/OpenHosta/pipelines/simple_pipeline.py | 4 ++ tests/edgecases/perma_enum.py | 40 ++++++++++++ tests/guarded/test_resolver.py | 64 ++++++++++++++++++++ 7 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 tests/edgecases/perma_enum.py diff --git a/.gitignore b/.gitignore index aae451d8..c7e9d84b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ capabilities.yaml final_capabilities.yaml **/.scannerwork logs/ -.supported_providers.yaml \ No newline at end of file +.supported_providers.yaml +scratch/* diff --git a/src/OpenHosta/core/analizer.py b/src/OpenHosta/core/analizer.py index c72b6fa5..1ed3a3ea 100644 --- a/src/OpenHosta/core/analizer.py +++ b/src/OpenHosta/core/analizer.py @@ -121,6 +121,14 @@ def nice_type_name(p_type) -> str: return name[8:] return name + # Handle Guarded[T] - unwrap to inner type for cleaner display + if hasattr(p_type, "__origin__"): + from OpenHosta.guarded.primitives import Guarded + if p_type.__origin__ is Guarded: + args = getattr(p_type, "__args__", ()) + if args: + return nice_type_name(args[0]) + # Handle typing types and GenericAlias (tuple[int, ...], List[str], etc.) if is_typing_type(p_type) or hasattr(p_type, "__origin__"): t=repr(p_type) diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index 07c21743..ac321f5e 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -65,14 +65,13 @@ """ from abc import ABC, ABCMeta -from typing import Any, Tuple, ClassVar, Dict, Optional, Literal +from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeAlias, NewType from dataclasses import dataclass, is_dataclass, fields # Imports internes (Moteur & Incertitude) from .constants import Tolerance, ToleranceLevel - AbstractionLevel = Literal["native", "heuristic", "semantic", "knowledge", "failed"] UncertaintyLevel = float @@ -205,7 +204,8 @@ def __new__(cls, *args: Any, **kwargs: Any): # 4. Injection des Métadonnées instance._input = value - instance._uncertainty = result.uncertainty + instance._casting_uncertainty = result.uncertainty + instance._source_uncertainty = None instance._abstraction_level = result.abstraction instance._python_value = result.data @@ -239,10 +239,31 @@ def __init__(self, *args: Any, **kwargs: Any): # Pour les types immuables (int, str...), __init__ ne fait rien car # la valeur a déjà été fixée dans __new__. + @property + def casting_uncertainty(self) -> UncertaintyLevel: + """Uncertainty from the type-casting pipeline layer used to produce this value (0.0 to 1.0).""" + return getattr(self, '_casting_uncertainty', 1.0) + + @property + def source_uncertainty(self) -> UncertaintyLevel: + """ + Uncertainty of the data source that produced this value (0.0 to 1.0). + For LLM-produced values, this comes from token-level logprobs. + Returns None if no source uncertainty was measured. + """ + return getattr(self, '_source_uncertainty', None) + @property def uncertainty(self) -> UncertaintyLevel: - """Score de confiance de la conversion (0.0 à 1.0).""" - return getattr(self, '_uncertainty', 1.0) + """ + Combined uncertainty: 1 - (1 - casting) * (1 - source). + If source uncertainty is not available, falls back to casting uncertainty alone. + """ + c = getattr(self, '_casting_uncertainty', 1.0) + s = getattr(self, '_source_uncertainty', None) + if s is None: + return c + return 1.0 - (1.0 - c) * (1.0 - s) @property def abstraction_level(self) -> str: @@ -351,7 +372,8 @@ def return_success(cleaned_val, uncertainty, level, message): instance = base_type.__new__(cls) instance._input = value - instance._uncertainty = uncertainty + instance._casting_uncertainty = uncertainty + instance._source_uncertainty = None instance._abstraction_level = level instance._python_value = cleaned_val @@ -524,3 +546,5 @@ def __contains__(self, item): def __getitem__(self, key): return self._python_value[key] + +Guarded:TypeAlias = GuardedPrimitive diff --git a/src/OpenHosta/guarded/subclassableclasses.py b/src/OpenHosta/guarded/subclassableclasses.py index 2502f08d..a803c845 100644 --- a/src/OpenHosta/guarded/subclassableclasses.py +++ b/src/OpenHosta/guarded/subclassableclasses.py @@ -97,9 +97,20 @@ def _build_type_py_repr(cls) -> str: return f"class {display_name}(Enum):\n{joined_members}" @property - def uncertainty(self) -> UncertaintyLevel: + def casting_uncertainty(self) -> UncertaintyLevel: + return getattr(self, "_casting_uncertainty", 1.0) + + @property + def source_uncertainty(self) -> UncertaintyLevel: + return getattr(self, "_source_uncertainty", None) - return getattr(self, "_uncertainty", 1.0) + @property + def uncertainty(self) -> UncertaintyLevel: + c = getattr(self, "_casting_uncertainty", 1.0) + s = getattr(self, "_source_uncertainty", None) + if s is None: + return c + return 1.0 - (1.0 - c) * (1.0 - s) @property def abstraction_level(self) -> str: diff --git a/src/OpenHosta/pipelines/simple_pipeline.py b/src/OpenHosta/pipelines/simple_pipeline.py index 29d5e634..8fe81c88 100644 --- a/src/OpenHosta/pipelines/simple_pipeline.py +++ b/src/OpenHosta/pipelines/simple_pipeline.py @@ -309,6 +309,10 @@ def pull_check_uncertainty(self, inspection:Inspection) -> Any: inspection.logs["enum_normalized_probs"] = normalized_probability inspection.logs["uncertainty"] = uncertainty + # Inject logprobs-based source uncertainty into the Guarded object + if isinstance(result, (GuardedPrimitive, ProxyWrapper)): + result._source_uncertainty = uncertainty + prompt_hash = hash( str(inspection.logs.get("llm_api_messages_sent", "")) ) for v in reproducible_settings.values(): diff --git a/tests/edgecases/perma_enum.py b/tests/edgecases/perma_enum.py new file mode 100644 index 00000000..6c3aaff2 --- /dev/null +++ b/tests/edgecases/perma_enum.py @@ -0,0 +1,40 @@ +from OpenHosta import emulate + +from enum import Enum + +class Rating(Enum): + UNIVERSAL_AND_PROVEN = 'universal_and_proven' # value_type: str + UNIVERSAL_BUT_UNPROVEN = 'universal_but_unproven' # value_type: str + UNIVERSAL_BUT_DISPROVEN = 'universal_but_disproven' # value_type: str + CONTEXTUAL_AND_PROVEN = 'contextual_and_proven' # value_type: str + CONTEXTUAL_BUT_UNPROVEN = 'contextual_but_unproven' # value_type: str + CONTEXTUAL_BUT_DISPROVEN = 'contextual_but_disproven' # value_type: str + PURE_SPECULATION = 'pure_speculation' # value_type: str + +def evaluate_idea_scope(idea: str, context: str) -> Rating: + """ + + Evaluer si l'idee ``idee`` telle que decrite est indépendante du contexte particulier (universelle) + ou si elle est liée au contexte spécifique décrit dans `contexte` (contextuelle). + Sa mise en oeuvre peut être évidente (proven), incertaine (unproven) ou impossible (disproven). + + Returns a Rating enum value. + + """ + return emulate() + +idea="Installer un système de paillage épais avec des feuilles mortes et de l'herbe tondue pour protéger le sol tourbeux, réduire l'arrosage et empêcher la prolifération des limaces en leur ôtant des abris." + +context="## Description initiale du jardin \n\n### Localisation\nVille: Göteborg, Suède\nOrientation: Nord\nClimat: Tempéré et océanique \nSol: Tourbeux, peu de consistence, assez pauvre\nDimensions à déterminer\nHumidité du climat, mais la terre retient peu l'eau\n\n### Composition\nLe jardin est en pente du haut (Nord-Ouest) vers la maison (Sud). \nIl est bordé au nord par des rochers, au nord-ouest par 5 arbres (érables et chênes),\nau sud-ouest par une palissade, au sud-est par le jardin du voisin, au sud par la maison. \nLes zones proches de la maison et du jardin du voisin sont donc ombragées une partie de la journée, sauf en plein été quand le soleil est vraiment haut. \n\n### Actuelle\ndeux plants de framboisiers déjà plantés vers les érables\n\n### Ravageurs\nSurtout des limaces\n\n\n## Informations et descriptions supplementaires suite à l'analyse du retour utilisateur\n\nLe jardinier souhaite des solutions simples, réversibles et peu coûteuses, adaptées à une occupation temporaire de deux ans. Des méthodes comme les lasagnes végétales ou la butte Hugelkultur sont jugées trop complexes et sont écartées. Le paillage épais avec des feuilles mortes et de l’herbe tondue est maintenu et renforcé, car apprécié pour sa simplicité et son efficacité. L’idée de planter du fenouil, de la coriandre et de l’aneth autour des framboisiers est confirmée comme une piste réaliste pour créer une guilde végétale attractrice d’auxiliaires, même si la coriandre peut poser des contraintes de culture. Les protections contre les limaces à base de bouteilles en plastique coupées et d’anneaux de cuivre sont consolidées comme méthode fiable, facile à déplacer et peu coûteuse. La planification de successions échelonnées pour les radis et les salades est adoptée pour étaler les récoltes. L’association de plantes répulsives comme la ciboulette et le thym est maintenue, mais avec une attention portée à la rotation des cultures en raison de la persistance de la ciboulette. Un banc d’observation modulaire ou préfabriqué est recommandé pour limiter les efforts de bricolage. La culture en bacs en bois ou plastique recyclé près de la maison est retenue comme solution optimale pour les salades, radis et microsalades, permettant un meilleur contrôle de l’humidité et une protection contre les limaces. Le marc de café comme répulsif est écarté en raison de la quantité nécessaire. L’idée de tipis pour haricots est adaptée à la pente et au rocher via des structures souples ou des associations en lignes courbes, mais écartée comme système fixe. Le trèfle blanc comme couvre-sol est considéré comme non prioritaire, étant donné la végétation déjà présente. Le semis échelonné des haricots et pois gourmands est intégré au planning de culture pour répartir l’effort d’entretien. L’arrosage ciblé le matin avec un arrosoir est maintenu comme pratique adaptée au climat humide." + + +try: + value = evaluate_idea_scope(idea, context) + print("Value:", value) +except Exception as e: + print("error", e) + +from OpenHosta import print_last_decoding, print_last_prompt + +print_last_prompt(evaluate_idea_scope) +print_last_decoding(evaluate_idea_scope) \ No newline at end of file diff --git a/tests/guarded/test_resolver.py b/tests/guarded/test_resolver.py index 78de1ae3..eb0e55fc 100644 --- a/tests/guarded/test_resolver.py +++ b/tests/guarded/test_resolver.py @@ -428,3 +428,67 @@ def _parse_native(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[str] # Should be a CorporateEmail instance assert isinstance(result, str) assert str(result) == "marie.dupont@mycorp.com" + + +class TestNiceTypeWithNameGuardedT: + """Test that nice_type_name properly unwraps Guarded[T] to display the inner type.""" + + def test_guarded_class_returns_class_name(self): + """Guarded[SomeClass] should display as the class name, not Guarded[SomeClass].""" + from OpenHosta.core.analizer import nice_type_name + from OpenHosta import Guarded + + class Sentiment: + pass + + result = nice_type_name(Guarded[Sentiment]) + assert result == "Sentiment", f"Expected 'Sentiment', got '{result}'" + + def test_guarded_builtin_returns_builtin_name(self): + """Guarded[str], Guarded[int], etc. should display as the builtin name.""" + from OpenHosta.core.analizer import nice_type_name + from OpenHosta import Guarded + + assert nice_type_name(Guarded[str]) == "str" + assert nice_type_name(Guarded[int]) == "int" + assert nice_type_name(Guarded[float]) == "float" + assert nice_type_name(Guarded[bool]) == "bool" + + def test_guarded_generic_returns_inner_generic(self): + """Guarded[List[int]] should display as List[int].""" + from OpenHosta.core.analizer import nice_type_name + from OpenHosta import Guarded + + result = nice_type_name(Guarded[List[int]]) + assert result == "List[int]", f"Expected 'List[int]', got '{result}'" + + def test_guarded_nested_generic(self): + """Guarded[Dict[str, int]] should display as Dict[str, int].""" + from OpenHosta.core.analizer import nice_type_name + from OpenHosta import Guarded + + result = nice_type_name(Guarded[Dict[str, int]]) + assert result == "Dict[str, int]", f"Expected 'Dict[str, int]', got '{result}'" + + def test_guarded_optional(self): + """Guarded[Optional[str]] should display as Optional[str].""" + from OpenHosta.core.analizer import nice_type_name + from OpenHosta import Guarded + + result = nice_type_name(Guarded[Optional[str]]) + assert result == "Optional[str]", f"Expected 'Optional[str]', got '{result}'" + + def test_regular_types_unchanged(self): + """Non-Guarded types should still work normally.""" + from OpenHosta.core.analizer import nice_type_name + + assert nice_type_name(str) == "str" + assert nice_type_name(int) == "int" + assert nice_type_name(None) == "Any" + + def test_regular_generics_unchanged(self): + """Non-Guarded generic types should still work normally.""" + from OpenHosta.core.analizer import nice_type_name + + assert nice_type_name(List[int]) == "List[int]" + assert nice_type_name(Dict[str, int]) == "Dict[str, int]" From 5e809a02a71481c3006d51f567dd9f30b5b40c8e Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Tue, 28 Apr 2026 10:07:20 +0200 Subject: [PATCH 29/33] fix: Enum strip missing regression was due to refactoring on Collable parsing implementation --- docs/index.md | 2 +- pyproject.toml | 2 +- src/OpenHosta/__init__.py | 2 +- src/OpenHosta/guarded/subclassableclasses.py | 2 +- tests/edgecases/perma_enum.py | 67 ++++++++++++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 tests/edgecases/perma_enum.py diff --git a/docs/index.md b/docs/index.md index 878c4517..f990cf0b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # OpenHosta Documentation -**Version 4.1** · [GitHub](https://github.com/hand-e-fr/OpenHosta) · [PyPI](https://pypi.org/project/OpenHosta/) +**Version 4** · [GitHub](https://github.com/hand-e-fr/OpenHosta) · [PyPI](https://pypi.org/project/OpenHosta/) Welcome to the **OpenHosta** documentation. OpenHosta is the semantic layer for Python — it transforms human language and type annotations into executable, type-safe Python functions powered by Large Language Models. diff --git a/pyproject.toml b/pyproject.toml index 6cde66ce..53fdba21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "OpenHosta" -version = "4.2.1" +version = "4.2.2" description = "A lightweight library integrating LLM natively into Python" keywords = ["AI", "GPT", "Natural language", "Autommatic", "Easy"] authors = [ diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index 4ed71acf..0556a4b4 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.2.1" +__version__ = "4.2.2" from .defaults import config from .defaults import reload_dotenv diff --git a/src/OpenHosta/guarded/subclassableclasses.py b/src/OpenHosta/guarded/subclassableclasses.py index 2502f08d..80d53659 100644 --- a/src/OpenHosta/guarded/subclassableclasses.py +++ b/src/OpenHosta/guarded/subclassableclasses.py @@ -138,7 +138,7 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s """Recherche case-insensitive par nom ou par valeur.""" value = str(value) - cleaned_val = value.strip() + cleaned_val = value.strip(" `'\"\n") if cleaned_val.startswith("<") and cleaned_val.endswith(">"): cleaned_val = cleaned_val[1:-1].strip() diff --git a/tests/edgecases/perma_enum.py b/tests/edgecases/perma_enum.py new file mode 100644 index 00000000..4a90b9b1 --- /dev/null +++ b/tests/edgecases/perma_enum.py @@ -0,0 +1,67 @@ +## This produces an error with qwen3-235b-a22b-instruct-2507 on v4.2.1 + +from OpenHosta import emulate + +from enum import Enum + +class Rating(Enum): + UNIVERSAL_AND_PROVEN = 'universal_and_proven' # value_type: str + UNIVERSAL_BUT_UNPROVEN = 'universal_but_unproven' # value_type: str + UNIVERSAL_BUT_DISPROVEN = 'universal_but_disproven' # value_type: str + CONTEXTUAL_AND_PROVEN = 'contextual_and_proven' # value_type: str + CONTEXTUAL_BUT_UNPROVEN = 'contextual_but_unproven' # value_type: str + CONTEXTUAL_BUT_DISPROVEN = 'contextual_but_disproven' # value_type: str + PURE_SPECULATION = 'pure_speculation' # value_type: str + +def evaluate_idea_scope(idea: str, context: str) -> Rating: + """ + + Evaluer si l'idee ``idee`` telle que decrite est indépendante du contexte particulier (universelle) + ou si elle est liée au contexte spécifique décrit dans `contexte` (contextuelle). + Sa mise en oeuvre peut être évidente (proven), incertaine (unproven) ou impossible (disproven). + + Returns a Rating enum value. + + """ + return emulate() + +idea="Installer un système de paillage épais avec des feuilles mortes et de l'herbe tondue pour protéger le sol tourbeux, réduire l'arrosage et empêcher la prolifération des limaces en leur ôtant des abris." + +context="## Description initiale du jardin \n\n### Localisation\nVille: Göteborg, Suède\nOrientation: Nord\nClimat: Tempéré et océanique \nSol: Tourbeux, peu de consistence, assez pauvre\nDimensions à déterminer\nHumidité du climat, mais la terre retient peu l'eau\n\n### Composition\nLe jardin est en pente du haut (Nord-Ouest) vers la maison (Sud). \nIl est bordé au nord par des rochers, au nord-ouest par 5 arbres (érables et chênes),\nau sud-ouest par une palissade, au sud-est par le jardin du voisin, au sud par la maison. \nLes zones proches de la maison et du jardin du voisin sont donc ombragées une partie de la journée, sauf en plein été quand le soleil est vraiment haut. \n\n### Actuelle\ndeux plants de framboisiers déjà plantés vers les érables\n\n### Ravageurs\nSurtout des limaces\n\n\n## Informations et descriptions supplementaires suite à l'analyse du retour utilisateur\n\nLe jardinier souhaite des solutions simples, réversibles et peu coûteuses, adaptées à une occupation temporaire de deux ans. Des méthodes comme les lasagnes végétales ou la butte Hugelkultur sont jugées trop complexes et sont écartées. Le paillage épais avec des feuilles mortes et de l’herbe tondue est maintenu et renforcé, car apprécié pour sa simplicité et son efficacité. L’idée de planter du fenouil, de la coriandre et de l’aneth autour des framboisiers est confirmée comme une piste réaliste pour créer une guilde végétale attractrice d’auxiliaires, même si la coriandre peut poser des contraintes de culture. Les protections contre les limaces à base de bouteilles en plastique coupées et d’anneaux de cuivre sont consolidées comme méthode fiable, facile à déplacer et peu coûteuse. La planification de successions échelonnées pour les radis et les salades est adoptée pour étaler les récoltes. L’association de plantes répulsives comme la ciboulette et le thym est maintenue, mais avec une attention portée à la rotation des cultures en raison de la persistance de la ciboulette. Un banc d’observation modulaire ou préfabriqué est recommandé pour limiter les efforts de bricolage. La culture en bacs en bois ou plastique recyclé près de la maison est retenue comme solution optimale pour les salades, radis et microsalades, permettant un meilleur contrôle de l’humidité et une protection contre les limaces. Le marc de café comme répulsif est écarté en raison de la quantité nécessaire. L’idée de tipis pour haricots est adaptée à la pente et au rocher via des structures souples ou des associations en lignes courbes, mais écartée comme système fixe. Le trèfle blanc comme couvre-sol est considéré comme non prioritaire, étant donné la végétation déjà présente. Le semis échelonné des haricots et pois gourmands est intégré au planning de culture pour répartir l’effort d’entretien. L’arrosage ciblé le matin avec un arrosoir est maintenu comme pratique adaptée au climat humide." + + +try: + value = evaluate_idea_scope(idea, context) + print("Value:", value) +except Exception as e: + print("error", e) + +from OpenHosta import print_last_decoding, print_last_prompt + +# print_last_prompt(evaluate_idea_scope) +# print_last_decoding(evaluate_idea_scope) + +from OpenHosta.guarded import TypeResolver + +GuardedRating = TypeResolver.resolve(Rating) +GuardedRating.attempt("""`Rating.CONTEXTUAL_AND_PROVEN`""") + +original_llm_output="""\ +```python +Rating.CONTEXTUAL_AND_PROVEN +``` + +### Rationale (simulated reasoning): + +The idea of installing a thick mulch layer using fallen leaves and grass clippings is **well-suited to the specific garden conditions described in the context**: +- The **peaty soil** that retains little water benefits from mulch to conserve moisture. +- The **cool, humid oceanic climate of Gothenburg** favors slug proliferation, and removing their shelters (as mulch made of leaves and grass can sometimes provide shelter if improperly applied) is noted as a concern — however, in this case, the thick mulch is **strategically used and reinforced**, suggesting it has been proven to work when applied correctly. +- The gardener values **simple, reversible, low-cost solutions**, and the continuation/reinforcement of this mulching practice indicates its effectiveness in this specific setup. + +However, while mulching is a general technique, **this particular use** — thick layer with local organic materials (leaves, clippings), on peaty soil, in a shaded, humid, slug-prone environment, and tied to other design choices (e.g., placement near raspberries, integration with temporary layout) — makes it **highly contextual**. + +Moreover, the solution is **explicitly confirmed as effective ("maintenu et renforcé")**, so it is **proven in practice here**, even if it might not work the same way elsewhere (e.g., in dry or compacted soils). + +Thus, the rating is: +→ **CONTEXTUAL_AND_PROVEN**. +""" From 0d03fecd017e245e2d5432c2d77805cb2b2ef0d1 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Tue, 28 Apr 2026 22:50:43 +0200 Subject: [PATCH 30/33] fix: adjust tests, fix heuristic in not str as input Co-authored-by: Copilot --- src/OpenHosta/guarded/primitives.py | 2 +- tests/exec/test_emulate_generator.py | 2 +- tests/functionnal/test_emulate.py | 43 +++++++++++++++------------ tests/guarded/test_resolver.py | 3 +- tests/guarded/test_typed_complex.py | 2 +- tests/typing/test_compat_logistics.py | 4 +-- tests/typing/test_long_tuple.py | 11 ++++--- 7 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index 45aa205d..e1ede9aa 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -337,7 +337,7 @@ def _clean_llm_response(cls, value: str) -> str: - Supprime les explications textuelles avant/après l'expression """ if not isinstance(value, str): - return value + value = str(value) cleaned = value.strip() diff --git a/tests/exec/test_emulate_generator.py b/tests/exec/test_emulate_generator.py index 30c43b08..343723a7 100644 --- a/tests/exec/test_emulate_generator.py +++ b/tests/exec/test_emulate_generator.py @@ -67,7 +67,7 @@ async def test_emulate_async_generator(): async def my_async_gen() -> AsyncIterator[int]: """A test docstring.""" - async for x in await emulate_async(pipeline=pipeline): + async for x in emulate_async(pipeline=pipeline): yield x results = [] diff --git a/tests/functionnal/test_emulate.py b/tests/functionnal/test_emulate.py index 09717397..e03f6eee 100644 --- a/tests/functionnal/test_emulate.py +++ b/tests/functionnal/test_emulate.py @@ -271,27 +271,32 @@ class Weather(StrEnum): RAINY = "rainy" CLOUDY = "cloudy" - async def app(): - async def get_weather_predictor() -> Callable[[float, float], Weather]: - """ - Write a python function that predicts the weather based on temperature and humidity. - The function takes temperature (float) and humidity (float) as arguments and returns a Weather enum. - - If humidity > 80, it's rainy. - - If humidity < 50 and temperature > 20, it's sunny. - - Otherwise, it's cloudy. - """ - return await emulate_async() - - return await get_weather_predictor() - - predictor = run(app()) + async def get_weather_predictor() -> Callable[[float, float], Weather]: + """ + Write a python function that predicts the weather based on temperature and humidity. + The function takes temperature (float) and humidity (float) as arguments and returns a Weather enum. + - If humidity > 80, it's rainy. + - If humidity < 50 and temperature > 20, it's sunny. + - Otherwise, it's cloudy. + """ + return await emulate_async() + + predictor = run(get_weather_predictor()) assert callable(predictor), f"Expected a callable, got: {type(predictor)}" - - # Test the generated function - assert predictor(25.0, 40.0) == Weather.SUNNY - assert predictor(15.0, 90.0) == Weather.RAINY - assert predictor(15.0, 60.0) == Weather.CLOUDY + + try: + # Check predictor annotation + assert predictor.__annotations__ == {"temperature": float, "humidity": float, "return": Weather} + + # Test the generated function + assert predictor(25.0, 40.0) == Weather.SUNNY + assert predictor(15.0, 90.0) == Weather.RAINY + assert predictor(15.0, 60.0) == Weather.CLOUDY + except Exception as e: + from OpenHosta import print_last_prompt + print_last_prompt(get_weather_predictor) + raise e def test_emulate_speed(): """ diff --git a/tests/guarded/test_resolver.py b/tests/guarded/test_resolver.py index 70b9b9f1..14907e04 100644 --- a/tests/guarded/test_resolver.py +++ b/tests/guarded/test_resolver.py @@ -115,9 +115,10 @@ def test_resolve_string_annotations(self): def test_string_annotation_emits_warning(self): """Test that string annotations trigger a deprecation warning.""" import warnings + TypeResolver._RESOLVE_CACHE.clear() with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - TypeResolver.resolve("int") + assert TypeResolver.resolve("int") == GuardedInt assert len(w) == 1 assert "gap in upstream type resolution" in str(w[0].message) diff --git a/tests/guarded/test_typed_complex.py b/tests/guarded/test_typed_complex.py index 2e8a3329..a54a3a50 100644 --- a/tests/guarded/test_typed_complex.py +++ b/tests/guarded/test_typed_complex.py @@ -108,7 +108,7 @@ def test_complex(self): from OpenHosta.guarded.subclassablescalars import GuardedComplex c = GuardedComplex("1+2j") assert c == complex(1, 2) - assert c.uncertainty == Tolerance.TYPE_COMPLIANT + assert c.uncertainty == Tolerance.PRECISE def test_bytes(self): from OpenHosta.guarded.subclassablescalars import GuardedBytes diff --git a/tests/typing/test_compat_logistics.py b/tests/typing/test_compat_logistics.py index 5d1373c9..ec6a1e66 100644 --- a/tests/typing/test_compat_logistics.py +++ b/tests/typing/test_compat_logistics.py @@ -278,8 +278,8 @@ def parse_order(text: str) -> Order: assert isinstance(result.items, list) assert len(result.items) == 2 assert isinstance(result.items[0], Item) - assert result.items[0].name == "Widgets" - assert result.items[1].name == "Gadget" + assert result.items[0].name.startswith("Widget") + assert result.items[1].name.startswith("Gadget") def test_pydantic_flat(self): def parse_parcel_label(text: str) -> ParcelLabel: diff --git a/tests/typing/test_long_tuple.py b/tests/typing/test_long_tuple.py index 6f9e44a9..912fca4b 100644 --- a/tests/typing/test_long_tuple.py +++ b/tests/typing/test_long_tuple.py @@ -17,7 +17,7 @@ def test_long_tuple_type_resolution(): # The original LLM output - a tuple with two long strings # First string: 10-step irrigation system implementation # Second string: Monthly implementation calendar - llm_output = ( + llm_output = """( "1. Évaluer les dimensions du jardin et calculer le besoin en eau journalier selon la surface cultivée, en tenant compte du sol tourbeux peu rétentif.\n" "2. Installer une cuve de 300 L de récupération d’eau de pluie, raccordée aux gouttières sud, avec filtre basique et surélevée pour bénéficier de la gravité.\n" "3. Concevoir un plan de pose des tuyaux goutte-à-goutte groupés par besoins hydriques (fraises, tomates, légumes).\n" @@ -62,7 +62,7 @@ def test_long_tuple_type_resolution(): "- Arrêt complet du système.\n" "- Vidange totale, rangement minuterie et filtres.\n" "- Conservation du réservoir partiellement ouvert pour éviter le gel." - ) + )""" # Define the type we want to resolve MyType = TypeResolver.resolve(Tuple[str, str]) @@ -82,6 +82,9 @@ def test_long_tuple_type_resolution(): assert "Évaluer les dimensions du jardin" in data[0] assert "Calendrier d'implémentation" in data[1] + assert llm_output[0] == data[0] + assert llm_output[1] == data[1] + # Test accessing the second element (calendar) calendar_content = data[1] assert "**Avril**" in calendar_content @@ -94,7 +97,7 @@ def test_long_tuple_type_resolution(): def test_long_tuple_attempt_method(): """Test the attempt method with long tuple data.""" - llm_output = ( + llm_output = """( "1. Évaluer les dimensions du jardin et calculer le besoin en eau journalier selon la surface cultivée, en tenant compte du sol tourbeux peu rétentif.\n" "2. Installer une cuve de 300 L de récupération d’eau de pluie, raccordée aux gouttières sud, avec filtre basique et surélevée pour bénéficier de la gravité.", "Calendrier d'implémentation du système d’irrigation goutte-à-goutte avec récupération d’eau de pluie (Göteborg, climat océanique) :\n" @@ -102,7 +105,7 @@ def test_long_tuple_attempt_method(): "**Avril**\n" "- **Semaine 1-2** : Analyse du jardin, tracé du réseau. Installation de la cuve de 300 L orientée sud avec filtre.\n" "- **Semaine 3** : Achat du matériel (tuyaux, raccords, minuterie mécanique, supports)." - ) + )""" MyType = TypeResolver.resolve(tuple[str, str]) From f74b82ba41899a66cd6d256f3f218923d1021e7b Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Tue, 28 Apr 2026 22:51:49 +0200 Subject: [PATCH 31/33] cicd: change version to v4.3.0 --- pyproject.toml | 2 +- src/OpenHosta/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 53fdba21..ed025dce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "OpenHosta" -version = "4.2.2" +version = "4.3.0" description = "A lightweight library integrating LLM natively into Python" keywords = ["AI", "GPT", "Natural language", "Autommatic", "Easy"] authors = [ diff --git a/src/OpenHosta/__init__.py b/src/OpenHosta/__init__.py index 8a9b66fd..a35b8918 100644 --- a/src/OpenHosta/__init__.py +++ b/src/OpenHosta/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.2.2" +__version__ = "4.3.0" from .defaults import config from .defaults import reload_dotenv From c06242fa7f7e19050ddb2e253abb087010323764 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Tue, 28 Apr 2026 23:05:57 +0200 Subject: [PATCH 32/33] fix: TypeAlias missing after merge conflict --- src/OpenHosta/guarded/primitives.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index cde9fc80..53256d74 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -66,7 +66,7 @@ import re from abc import ABC, ABCMeta -from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeVar, Generic +from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeVar, Generic, TypeAlias from dataclasses import dataclass, is_dataclass, fields From 6c02c6123825b56ce6d7e6b1d0537c55954710c7 Mon Sep 17 00:00:00 2001 From: BATT Emmanuel-EXT Date: Wed, 29 Apr 2026 00:30:28 +0200 Subject: [PATCH 33/33] fix: Guarded shall be declared as a class. remove safe() from functional tests --- src/OpenHosta/core/uncertainty.py | 8 ++++---- src/OpenHosta/guarded/primitives.py | 4 +--- src/OpenHosta/guarded/subclassableclasses.py | 8 ++++++++ tests/{functionnal => advanced}/test_safe.py | 20 ++++++++++++++------ tests/functionnal/test_closure.py | 4 ++-- tests/functionnal/test_func_ask_stream.py | 2 +- 6 files changed, 30 insertions(+), 16 deletions(-) rename tests/{functionnal => advanced}/test_safe.py (97%) diff --git a/src/OpenHosta/core/uncertainty.py b/src/OpenHosta/core/uncertainty.py index c4b368ed..9bf228e2 100644 --- a/src/OpenHosta/core/uncertainty.py +++ b/src/OpenHosta/core/uncertainty.py @@ -259,13 +259,13 @@ def get_enum_logprobes(*, function_pointer=None, inspection:Inspection=None) -> else: answer_part.append(token_data) - rational_certainty, branch_count = get_naive_certainty(rational_part) - print(f"Rational part uncertainty: {1 - rational_certainty:0.6f} over {branch_count} branches") + #rational_certainty, branch_count = get_naive_certainty(rational_part) + #print(f"Rational part uncertainty: {1 - rational_certainty:0.6f} over {branch_count} branches") logp_list = answer_part else: rational_uncertainty = 1 - + possible_outcomes = [( str(v.value), f'"{v.value}"', @@ -287,7 +287,7 @@ def get_enum_logprobes(*, function_pointer=None, inspection:Inspection=None) -> prior_prob_list = {k: math.exp(v) for k,v in logprobes.items()} previouse_string += prediction['token'] - if all([previouse_string not in v for v in possible_outcomes]): + if all([previouse_string.split("\n")[0] not in v for v in possible_outcomes]): raise UncertaintyError(f"The generated string '{previouse_string}' does not match any of the possible enum outcomes. Risk of hallucination.") return logprobes diff --git a/src/OpenHosta/guarded/primitives.py b/src/OpenHosta/guarded/primitives.py index 53256d74..a21411dd 100644 --- a/src/OpenHosta/guarded/primitives.py +++ b/src/OpenHosta/guarded/primitives.py @@ -66,7 +66,7 @@ import re from abc import ABC, ABCMeta -from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeVar, Generic, TypeAlias +from typing import Any, Tuple, ClassVar, Dict, Optional, Literal, TypeVar, Generic from dataclasses import dataclass, is_dataclass, fields @@ -647,5 +647,3 @@ def __contains__(self, item): def __getitem__(self, key): return self._python_value[key] - -Guarded:TypeAlias = GuardedPrimitive diff --git a/src/OpenHosta/guarded/subclassableclasses.py b/src/OpenHosta/guarded/subclassableclasses.py index 471b38ac..05fe5a5e 100644 --- a/src/OpenHosta/guarded/subclassableclasses.py +++ b/src/OpenHosta/guarded/subclassableclasses.py @@ -149,6 +149,14 @@ def _parse_heuristic(cls, value: Any) -> Tuple[UncertaintyLevel, Any, Optional[s """Recherche case-insensitive par nom ou par valeur.""" cleaned_val = cls._clean_llm_response(value) + + if len(cleaned_val) > 3 and "\n" in cleaned_val[1:-1]: + candidates = [GuardedEnum.attempt(p) for p in cleaned_val.split("\n")] + candidates = [c for c in candidates if c.success == True] + if len(candidates) > 0: + print(f"Multiple candidates found: {candidates}") + # TODO: return the most likely candidate based on the context of the document + return UncertaintyLevel(Tolerance.CREATIVE), candidates[0].value, None if cleaned_val.startswith("<") and cleaned_val.endswith(">"): cleaned_val = cleaned_val[1:-1].strip() diff --git a/tests/functionnal/test_safe.py b/tests/advanced/test_safe.py similarity index 97% rename from tests/functionnal/test_safe.py rename to tests/advanced/test_safe.py index 7b6ef08a..7b050b8b 100644 --- a/tests/functionnal/test_safe.py +++ b/tests/advanced/test_safe.py @@ -298,15 +298,13 @@ def test_sage_closure_color_detector_fail(): assert uncertainty > 0.1, \ f"Expected low confidence for all options, got: {uncertainty} above threshold: 0.1" - - def test_safe_workflow_color_detector(): from enum import Enum class Bool(Enum): - TRUE = "true" - FALSE = "false" + TRUE = 1 + FALSE = 2 def IsThisInThat(this_description:str, that_description:str)->Bool: """ @@ -319,6 +317,10 @@ def IsThisInThat(this_description:str, that_description:str)->Bool: """ return emulate() + # from OpenHosta import print_last_uncertainty, print_last_prompt + # print_last_uncertainty(IsThisInThat) + # print_last_prompt(IsThisInThat) + with safe(acceptable_cumulated_uncertainty=math.exp(-5)) as safe_context: ret = IsThisInThat("the sun", "the sky on a clear day") @@ -330,13 +332,16 @@ def IsThisInThat(this_description:str, that_description:str)->Bool: ret = IsThisInThat("hand", "finger") assert ret is Bool.FALSE, f"Expected FALSE for hand in finger, got: {ret}" + with safe(acceptable_cumulated_uncertainty=math.exp(-5)) as safe_context: + try: - ret = IsThisInThat("train 42535", "Paris Train station") + ret = IsThisInThat("45785", "Paris Train station") + print_last_prompt(IsThisInThat) except UncertaintyError as e: print(f"Caught expected UncertaintyError due to uncertainty: {e}") ret = None - assert ret is None, f"Expected None for train in station due to uncertainty error, got: {ret}" + assert ret is None, f"Expected None for train in station due to uncertainty error, got: {ret} with {safe_context}" print(safe_context) @@ -404,6 +409,9 @@ def find_organ_location(organ:str)->str | None: assert location is None, f"Expected None for blood location due to uncertainty error, got: {location}" + # from OpenHosta import print_last_prompt + # print_last_prompt(IsThisInThat) + def test_safe_on_string_return(): def greet(name:str)->str: diff --git a/tests/functionnal/test_closure.py b/tests/functionnal/test_closure.py index 347aca7a..7fa82067 100644 --- a/tests/functionnal/test_closure.py +++ b/tests/functionnal/test_closure.py @@ -56,9 +56,9 @@ async def app(): def test_closure_routing_async(): async def app(): prompt = "what is a good next step after this command: (between 'git push', 'git commit', 'git status', 'git pull', 'git fetch')" - next_step = closure_async(prompt) + next_step = closure_async(prompt, force_return_type=str) return await next_step("git commit -m 'Initial commit'") response = run(app()) - assert "push" in response, f"Expected 'push' and 'origin' in response, got: {response}" + assert "push" in response, f"Expected 'push' in response, got: {response}" \ No newline at end of file diff --git a/tests/functionnal/test_func_ask_stream.py b/tests/functionnal/test_func_ask_stream.py index 3298f421..f65fe2d2 100644 --- a/tests/functionnal/test_func_ask_stream.py +++ b/tests/functionnal/test_func_ask_stream.py @@ -20,7 +20,7 @@ def test_ask_stream_basic(): def test_ask_stream_async_basic(): """Test that ask_stream_async yields chunks that form a complete answer.""" async def app(): - prompt = "Count to 3: 1, 2, 3" + prompt = "Count up to 3." chunks = [] async for chunk in ask_stream_async(prompt, interval_ms=10): chunks.append(chunk)