Skip to content

Commit d6d9309

Browse files
committed
feat: Introduce output verification framework, enhance writer error handling, and add cacheable annotation for GET methods.
1 parent 11f2cba commit d6d9309

16 files changed

Lines changed: 845 additions & 70 deletions

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,30 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.3.1] - 2026-03-12
6+
7+
### Fixed
8+
9+
- README — added 5 concrete verifier classes (`YAMLVerifier`, `SyntaxVerifier`,
10+
`RegistryVerifier`, `MagicBytesVerifier`, `JSONVerifier`) to Core Modules table
11+
for documentation completeness
12+
13+
---
14+
15+
## [0.3.0] - 2026-03-12
16+
17+
### Changed
18+
19+
- **apcore >= 0.13.0** — Upgraded minimum dependency to support new
20+
`ModuleAnnotations` caching and pagination fields:
21+
`cacheable`, `cache_ttl`, `cache_key_fields`, `paginated`, `pagination_style`
22+
- `infer_annotations_from_method()``GET` now also infers `cacheable=True`
23+
- `AIEnhancer` — Annotation inference prompt and acceptance logic extended
24+
to handle all 11 annotation fields (5 new: `cacheable`, `cache_ttl`,
25+
`cache_key_fields`, `paginated`, `pagination_style`)
26+
27+
---
28+
529
## [0.2.0] - 2026-03-11
630

731
### Added

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "apcore-toolkit"
7-
version = "0.2.0"
7+
version = "0.3.1"
88
description = "Shared scanner, schema extraction, and output toolkit for apcore framework adapters"
99
requires-python = ">=3.11"
1010
readme = "README.md"
@@ -23,7 +23,7 @@ keywords = [
2323
"toolkit",
2424
]
2525
dependencies = [
26-
"apcore>=0.9.0",
26+
"apcore>=0.13.0",
2727
"pydantic>=2.0",
2828
"PyYAML>=6.0",
2929
]

src/apcore_toolkit/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from apcore_toolkit.ai_enhancer import AIEnhancer
77
from apcore_toolkit.formatting import to_markdown
8+
from apcore_toolkit.output import get_writer
89
from apcore_toolkit.output.python_writer import PythonWriter
910
from apcore_toolkit.output.registry_writer import RegistryWriter
1011
from apcore_toolkit.output.types import WriteResult
@@ -14,7 +15,7 @@
1415
from apcore_toolkit.schema_utils import enrich_schema_descriptions
1516
from apcore_toolkit.types import ScannedModule
1617

17-
__version__ = "0.2.0"
18+
__version__ = "0.3.0"
1819

1920
__all__ = [
2021
"AIEnhancer",
@@ -26,6 +27,7 @@
2627
"YAMLWriter",
2728
"enrich_schema_descriptions",
2829
"flatten_pydantic_params",
30+
"get_writer",
2931
"resolve_target",
3032
"to_markdown",
3133
]

src/apcore_toolkit/ai_enhancer.py

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
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>,')

src/apcore_toolkit/output/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@
55

66
from __future__ import annotations
77

8+
from apcore_toolkit.output.errors import WriteError as WriteError
89
from apcore_toolkit.output.python_writer import PythonWriter
910
from apcore_toolkit.output.registry_writer import RegistryWriter
11+
from apcore_toolkit.output.types import Verifier as Verifier
12+
from apcore_toolkit.output.types import VerifyResult as VerifyResult
1013
from apcore_toolkit.output.types import WriteResult as WriteResult
14+
from apcore_toolkit.output.verifiers import JSONVerifier as JSONVerifier
15+
from apcore_toolkit.output.verifiers import MagicBytesVerifier as MagicBytesVerifier
16+
from apcore_toolkit.output.verifiers import RegistryVerifier as RegistryVerifier
17+
from apcore_toolkit.output.verifiers import SyntaxVerifier as SyntaxVerifier
18+
from apcore_toolkit.output.verifiers import YAMLVerifier as YAMLVerifier
1119
from apcore_toolkit.output.yaml_writer import YAMLWriter
1220

1321

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Error types for output writers."""
2+
3+
from __future__ import annotations
4+
5+
6+
class WriteError(Exception):
7+
"""Raised when a writer fails to write an artifact to disk.
8+
9+
Attributes:
10+
path: The file path that could not be written.
11+
cause: The underlying exception that caused the failure.
12+
"""
13+
14+
def __init__(self, path: str, cause: Exception) -> None:
15+
self.path = path
16+
self.cause = cause
17+
super().__init__(f"Failed to write {path}: {cause}")

src/apcore_toolkit/output/python_writer.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from pathlib import Path
1515
from typing import TYPE_CHECKING, Any
1616

17-
from apcore_toolkit.output.types import WriteResult
17+
from apcore_toolkit.output.errors import WriteError
18+
from apcore_toolkit.output.types import Verifier, WriteResult
19+
from apcore_toolkit.output.verifiers import run_verifier_chain
1820

1921
if TYPE_CHECKING:
2022
from apcore_toolkit.types import ScannedModule
@@ -43,6 +45,7 @@ def write(
4345
output_dir: str,
4446
dry_run: bool = False,
4547
verify: bool = False,
48+
verifiers: list[Verifier] | None = None,
4649
) -> list[WriteResult]:
4750
"""Write Python module files for each ScannedModule.
4851
@@ -51,6 +54,9 @@ def write(
5154
output_dir: Directory path to write files to.
5255
dry_run: If True, return results without writing to disk.
5356
verify: If True, verify written files have valid Python syntax.
57+
verifiers: Optional list of custom Verifier instances. When provided,
58+
these run after the built-in check (if verify=True). First failure
59+
stops the chain.
5460
5561
Returns:
5662
List of WriteResult instances.
@@ -84,12 +90,24 @@ def write(
8490
if file_path.exists():
8591
logger.warning("Overwriting existing file: %s", file_path)
8692

87-
file_path.write_text(code, encoding="utf-8")
93+
try:
94+
file_path.write_text(code, encoding="utf-8")
95+
except OSError as exc:
96+
raise WriteError(str(file_path), exc) from exc
8897
logger.debug("Written: %s", file_path)
8998

9099
result = WriteResult(module_id=module.module_id, path=str(file_path))
91100
if verify:
92101
result = self._verify(result, file_path)
102+
if result.verified and verifiers:
103+
chain_result = run_verifier_chain(verifiers, str(file_path), module.module_id)
104+
if not chain_result.ok:
105+
result = WriteResult(
106+
module_id=result.module_id,
107+
path=result.path,
108+
verified=False,
109+
verification_error=chain_result.error,
110+
)
93111
results.append(result)
94112

95113
return results
@@ -161,11 +179,10 @@ def _sanitize_identifier(name: str) -> str:
161179
return sanitized
162180

163181
@staticmethod
164-
def _validate_module_path(path: str) -> str:
182+
def _validate_module_path(path: str) -> None:
165183
"""Validate that *path* is a valid dotted Python import path."""
166184
if not _MODULE_PATH_RE.match(path):
167-
raise ValueError(f"Invalid module path: {path!r}. " f"Must be a valid dotted Python import path.")
168-
return path
185+
raise ValueError(f"Invalid module path: {path!r}. Must be a valid dotted Python import path.")
169186

170187
def _schema_to_params(self, schema: dict[str, Any]) -> list[str]:
171188
"""Convert a JSON Schema to Python function parameters."""

0 commit comments

Comments
 (0)