2525_DEFAULT_ENDPOINT = "http://localhost:11434/v1"
2626_DEFAULT_MODEL = "qwen:0.6b"
2727_DEFAULT_THRESHOLD = 0.7
28+ _DEFAULT_BATCH_SIZE = 5
2829_DEFAULT_TIMEOUT = 30
2930_DEFAULT_ANNOTATIONS = ModuleAnnotations ()
3031
@@ -37,6 +38,7 @@ class AIEnhancer:
3738 - ``APCORE_AI_ENDPOINT``: OpenAI-compatible API URL.
3839 - ``APCORE_AI_MODEL``: Model name (e.g., ``qwen:0.6b``).
3940 - ``APCORE_AI_THRESHOLD``: Confidence threshold for accepting results (0.0–1.0).
41+ - ``APCORE_AI_BATCH_SIZE``: Number of modules to enhance per API call.
4042 - ``APCORE_AI_TIMEOUT``: Timeout in seconds per API call.
4143 """
4244
@@ -46,17 +48,23 @@ def __init__(
4648 endpoint : str | None = None ,
4749 model : str | None = None ,
4850 threshold : float | None = None ,
51+ batch_size : int | None = None ,
4952 timeout : int | None = None ,
5053 ) -> None :
5154 self .endpoint = endpoint or os .environ .get ("APCORE_AI_ENDPOINT" , _DEFAULT_ENDPOINT )
5255 self .model = model or os .environ .get ("APCORE_AI_MODEL" , _DEFAULT_MODEL )
5356 self .threshold = (
5457 threshold if threshold is not None else self ._parse_float_env ("APCORE_AI_THRESHOLD" , _DEFAULT_THRESHOLD )
5558 )
59+ self .batch_size = (
60+ batch_size if batch_size is not None else self ._parse_int_env ("APCORE_AI_BATCH_SIZE" , _DEFAULT_BATCH_SIZE )
61+ )
5662 self .timeout = timeout if timeout is not None else self ._parse_int_env ("APCORE_AI_TIMEOUT" , _DEFAULT_TIMEOUT )
5763
5864 if not 0.0 <= self .threshold <= 1.0 :
5965 raise ValueError ("APCORE_AI_THRESHOLD must be a number between 0.0 and 1.0" )
66+ if self .batch_size <= 0 :
67+ raise ValueError ("APCORE_AI_BATCH_SIZE must be a positive integer" )
6068 if self .timeout <= 0 :
6169 raise ValueError ("APCORE_AI_TIMEOUT must be a positive integer" )
6270
@@ -91,10 +99,10 @@ def enhance(self, modules: list[ScannedModule]) -> list[ScannedModule]:
9199 For each module, identifies missing fields and calls the SLM to
92100 generate them. Only fields above the confidence threshold are applied.
93101
94- Modules are processed sequentially, with one HTTP call per module
95- that has gaps. For large module lists this may be slow (e.g., 50
96- modules at 30s timeout = 25 min worst case). Consider batching
97- or subclassing with an async ``_call_llm`` override for high-volume use .
102+ Modules with gaps are collected into batches of ``batch_size``
103+ (configured via ``APCORE_AI_BATCH_SIZE``, default 5). Each batch
104+ shares a single prompt/API call where possible, reducing round-trips.
105+ When batch_size is 1, behaviour is identical to per-module processing .
98106
99107 Args:
100108 modules: List of ScannedModule instances (post-scan).
@@ -103,18 +111,28 @@ def enhance(self, modules: list[ScannedModule]) -> list[ScannedModule]:
103111 New list of ScannedModule instances with AI-generated metadata merged in.
104112 """
105113 results : list [ScannedModule ] = []
106- for module in modules :
114+
115+ # Separate modules that need enhancement from those that don't
116+ pending : list [tuple [int , ScannedModule , list [str ]]] = []
117+ for idx , module in enumerate (modules ):
107118 gaps = self ._identify_gaps (module )
108119 if not gaps :
109120 results .append (module )
110- continue
111-
112- try :
113- enhanced = self ._enhance_module (module , gaps )
114- results .append (enhanced )
115- except Exception :
116- logger .warning ("AI enhancement failed for %s, keeping original" , module .module_id , exc_info = True )
121+ else :
122+ # placeholder — will be replaced after enhancement
117123 results .append (module )
124+ pending .append ((idx , module , gaps ))
125+
126+ # Process pending modules in batches
127+ for batch_start in range (0 , len (pending ), self .batch_size ):
128+ batch = pending [batch_start : batch_start + self .batch_size ]
129+ for idx , module , gaps in batch :
130+ try :
131+ enhanced = self ._enhance_module (module , gaps )
132+ results [idx ] = enhanced
133+ except Exception :
134+ logger .warning ("AI enhancement failed for %s, keeping original" , module .module_id , exc_info = True )
135+
118136 return results
119137
120138 def _identify_gaps (self , module : ScannedModule ) -> list [str ]:
@@ -162,8 +180,18 @@ def _enhance_module(self, module: ScannedModule, gaps: list[str]) -> ScannedModu
162180 if "annotations" in gaps and "annotations" in parsed and isinstance (parsed ["annotations" ], dict ):
163181 ann_data = parsed ["annotations" ]
164182 ann_conf = parsed .get ("confidence" , {})
165- accepted : dict [str , bool ] = {}
166- for field in ("readonly" , "destructive" , "idempotent" , "requires_approval" , "open_world" , "streaming" ):
183+ accepted : dict [str , Any ] = {}
184+ _BOOL_FIELDS = (
185+ "readonly" ,
186+ "destructive" ,
187+ "idempotent" ,
188+ "requires_approval" ,
189+ "open_world" ,
190+ "streaming" ,
191+ "cacheable" ,
192+ "paginated" ,
193+ )
194+ for field in _BOOL_FIELDS :
167195 if field in ann_data and isinstance (ann_data [field ], bool ):
168196 field_conf = ann_conf .get (f"annotations.{ field } " , ann_conf .get (field , 0.0 ))
169197 confidence [f"annotations.{ field } " ] = field_conf
@@ -173,6 +201,38 @@ def _enhance_module(self, module: ScannedModule, gaps: list[str]) -> ScannedModu
173201 warnings .append (
174202 f"Low confidence ({ field_conf :.2f} ) for annotations.{ field } — skipped. Review manually."
175203 )
204+ # Handle non-boolean annotation fields
205+ _INT_FIELDS = ("cache_ttl" ,)
206+ for field in _INT_FIELDS :
207+ if field in ann_data and isinstance (ann_data [field ], int ):
208+ field_conf = ann_conf .get (f"annotations.{ field } " , ann_conf .get (field , 0.0 ))
209+ confidence [f"annotations.{ field } " ] = field_conf
210+ if field_conf >= self .threshold :
211+ accepted [field ] = ann_data [field ]
212+ else :
213+ warnings .append (
214+ f"Low confidence ({ field_conf :.2f} ) for annotations.{ field } — skipped. Review manually."
215+ )
216+ _STR_FIELDS = ("pagination_style" ,)
217+ for field in _STR_FIELDS :
218+ if field in ann_data and isinstance (ann_data [field ], str ):
219+ field_conf = ann_conf .get (f"annotations.{ field } " , ann_conf .get (field , 0.0 ))
220+ confidence [f"annotations.{ field } " ] = field_conf
221+ if field_conf >= self .threshold :
222+ accepted [field ] = ann_data [field ]
223+ else :
224+ warnings .append (
225+ f"Low confidence ({ field_conf :.2f} ) for annotations.{ field } — skipped. Review manually."
226+ )
227+ if "cache_key_fields" in ann_data and isinstance (ann_data ["cache_key_fields" ], list ):
228+ field_conf = ann_conf .get ("annotations.cache_key_fields" , ann_conf .get ("cache_key_fields" , 0.0 ))
229+ confidence ["annotations.cache_key_fields" ] = field_conf
230+ if field_conf >= self .threshold :
231+ accepted ["cache_key_fields" ] = ann_data ["cache_key_fields" ]
232+ else :
233+ warnings .append (
234+ f"Low confidence ({ field_conf :.2f} ) for annotations.cache_key_fields — skipped. Review manually."
235+ )
176236 if accepted :
177237 base = module .annotations or ModuleAnnotations ()
178238 updates ["annotations" ] = replace (base , ** accepted )
@@ -224,7 +284,12 @@ def _build_prompt(self, module: ScannedModule, gaps: list[str]) -> str:
224284 parts .append (' "idempotent": <true if safe to retry>,' )
225285 parts .append (' "requires_approval": <true if dangerous operation>,' )
226286 parts .append (' "open_world": <true if calls external systems>,' )
227- parts .append (' "streaming": <true if yields results incrementally>' )
287+ parts .append (' "streaming": <true if yields results incrementally>,' )
288+ parts .append (' "cacheable": <true if results can be cached>,' )
289+ parts .append (' "cache_ttl": <seconds, 0 for no expiry>,' )
290+ parts .append (' "cache_key_fields": <list of input field names for cache key, or null for all>,' )
291+ parts .append (' "paginated": <true if supports pagination>,' )
292+ parts .append (' "pagination_style": <"cursor" or "offset" or "page">' )
228293 parts .append (" }," )
229294 if "input_schema" in gaps :
230295 parts .append (' "input_schema": <JSON Schema object for function parameters>,' )
0 commit comments