fixtureforge 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/PKG-INFO +8 -2
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/README.md +7 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/pyproject.toml +2 -2
- fixtureforge-2.2.0/src/fixtureforge/ai/__init__.py +25 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/_audit_log.py +44 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/_groq_client.py +109 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/failure_analyzer.py +253 -0
- fixtureforge-2.2.0/src/fixtureforge/ai/semantic_assert.py +252 -0
- fixtureforge-2.1.0/src/fixtureforge/ai/__init__.py +0 -10
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/LICENSE +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/ai/cache.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/ai/engine.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/ai/prompts.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/cli/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/cli/commands.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/config/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/config/flags.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/analyzer.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/batch_engine.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/compression.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/dataset.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/exporter.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/generator.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/parser.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/recipe.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/router.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/streamer.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/core/swarm.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/integrations/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/integrations/github.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/integrations/jira.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/library/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/library/sharing.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/library/storage.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/memory/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/memory/dream.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/memory/store.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/anthropic.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/base.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/factory.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/gemini.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/groq.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/ollama.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/providers/openai.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/pyproject.toml +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/pytest_plugin.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/security/__init__.py +0 -0
- {fixtureforge-2.1.0 → fixtureforge-2.2.0}/src/fixtureforge/security/permissions.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureforge
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Agentic Test Data Harness: memory, multi-agent swarms, permission gates, coverage analysis. Provider-agnostic (Gemini, OpenAI, Anthropic, Ollama).
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
|
-
Keywords:
|
|
7
|
+
Keywords: pytest,fixtures,test-data,pydantic,faker,factory,ai,testing,mock-data,qa,automation,synthetic-data,llm,test-fixtures,fixture-generator
|
|
8
8
|
Author: Yaniv Metuku
|
|
9
9
|
Requires-Python: >=3.11,<4.0
|
|
10
10
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -32,6 +32,10 @@ Project-URL: Homepage, https://fixtureforge.dev
|
|
|
32
32
|
Project-URL: Repository, https://github.com/Yaniv2809/fixtureforge
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
|
+
<p align="center">
|
|
36
|
+
<img src="fixtureforge-logo.png" alt="FixtureForge Logo" width="400"/>
|
|
37
|
+
</p>
|
|
38
|
+
|
|
35
39
|
# FixtureForge
|
|
36
40
|
|
|
37
41
|
**Agentic Test Data Harness for Python.**
|
|
@@ -421,7 +425,9 @@ MIT — see [LICENSE](LICENSE).
|
|
|
421
425
|
|
|
422
426
|
## Links
|
|
423
427
|
|
|
428
|
+
- **Docs**: https://yaniv2809.github.io/fixtureforge/
|
|
424
429
|
- **PyPI**: https://pypi.org/project/fixtureforge/
|
|
425
430
|
- **Repository**: https://github.com/Yaniv2809/fixtureforge
|
|
426
431
|
- **Issues**: https://github.com/Yaniv2809/fixtureforge/issues
|
|
427
432
|
|
|
433
|
+
💬 [Join the discussion](https://github.com/Yaniv2809/fixtureforge/discussions/1)
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="fixtureforge-logo.png" alt="FixtureForge Logo" width="400"/>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# FixtureForge
|
|
2
6
|
|
|
3
7
|
**Agentic Test Data Harness for Python.**
|
|
@@ -387,6 +391,9 @@ MIT — see [LICENSE](LICENSE).
|
|
|
387
391
|
|
|
388
392
|
## Links
|
|
389
393
|
|
|
394
|
+
- **Docs**: https://yaniv2809.github.io/fixtureforge/
|
|
390
395
|
- **PyPI**: https://pypi.org/project/fixtureforge/
|
|
391
396
|
- **Repository**: https://github.com/Yaniv2809/fixtureforge
|
|
392
397
|
- **Issues**: https://github.com/Yaniv2809/fixtureforge/issues
|
|
398
|
+
|
|
399
|
+
💬 [Join the discussion](https://github.com/Yaniv2809/fixtureforge/discussions/1)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "fixtureforge"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.2.0"
|
|
4
4
|
description = "Agentic Test Data Harness: memory, multi-agent swarms, permission gates, coverage analysis. Provider-agnostic (Gemini, OpenAI, Anthropic, Ollama)."
|
|
5
5
|
authors = ["Yaniv Metuku"]
|
|
6
6
|
license = "MIT"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
homepage = "https://fixtureforge.dev"
|
|
9
9
|
repository = "https://github.com/Yaniv2809/fixtureforge"
|
|
10
|
-
keywords = ["
|
|
10
|
+
keywords = ["pytest", "fixtures", "test-data", "pydantic", "faker", "factory", "ai", "testing", "mock-data", "qa", "automation", "synthetic-data", "llm", "test-fixtures", "fixture-generator"]
|
|
11
11
|
|
|
12
12
|
# ---------------------------------------------------------------------------
|
|
13
13
|
# Core dependencies — always installed, no AI required
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI-powered generation and validation for FixtureForge.
|
|
3
|
+
|
|
4
|
+
Core (always available):
|
|
5
|
+
from fixtureforge.ai import AIEngine, ResponseCache
|
|
6
|
+
|
|
7
|
+
v2.2.0 — Opt-in AI features (require GROQ_API_KEY for LLM mode):
|
|
8
|
+
from fixtureforge.ai import assert_semantic_match, SemanticResult
|
|
9
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
10
|
+
"""
|
|
11
|
+
from .engine import AIEngine
|
|
12
|
+
from .cache import ResponseCache
|
|
13
|
+
from .semantic_assert import assert_semantic_match, SemanticResult, SemanticAssertionError
|
|
14
|
+
from .failure_analyzer import SmartFailureAnalyzer
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
# Core
|
|
18
|
+
"AIEngine",
|
|
19
|
+
"ResponseCache",
|
|
20
|
+
# v2.2.0 features
|
|
21
|
+
"assert_semantic_match",
|
|
22
|
+
"SemanticResult",
|
|
23
|
+
"SemanticAssertionError",
|
|
24
|
+
"SmartFailureAnalyzer",
|
|
25
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audit log for FixtureForge AI features.
|
|
3
|
+
|
|
4
|
+
Every LLM call made by assert_semantic_match or SmartFailureAnalyzer
|
|
5
|
+
is appended here as a JSONL record. Zero API calls are made during import.
|
|
6
|
+
|
|
7
|
+
Log location: .fixtureforge_ai_log.jsonl (project root / cwd)
|
|
8
|
+
Format: one JSON object per line, UTF-8, append-only.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
LOG_FILENAME = ".fixtureforge_ai_log.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _log_path() -> Path:
|
|
22
|
+
"""Return the audit log path relative to the current working directory."""
|
|
23
|
+
return Path(os.getcwd()) / LOG_FILENAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log_event(event_type: str, data: dict[str, Any]) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Append one event record to the audit log.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
event_type: Short label — e.g. "semantic_assert_llm", "failure_analysis".
|
|
32
|
+
data: Dict of arbitrary key-value pairs to record.
|
|
33
|
+
"""
|
|
34
|
+
record = {
|
|
35
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
36
|
+
"event": event_type,
|
|
37
|
+
**data,
|
|
38
|
+
}
|
|
39
|
+
try:
|
|
40
|
+
with open(_log_path(), "a", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
42
|
+
except OSError:
|
|
43
|
+
# Never crash a test run because of a logging failure.
|
|
44
|
+
pass
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight Groq REST client for FixtureForge AI features.
|
|
3
|
+
|
|
4
|
+
Uses only `requests` (already a core dependency) — no extra pip install needed.
|
|
5
|
+
Model: llama3-8b-8192 (fast, free tier, sufficient for analysis tasks).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from fixtureforge.ai._groq_client import groq_complete, LLMClientError
|
|
9
|
+
response = groq_complete(system="You are...", user="Analyze this...")
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
GROQ_API_URL = "https://api.groq.com/openai/v1/chat/completions"
|
|
21
|
+
DEFAULT_MODEL = "llama3-8b-8192"
|
|
22
|
+
DEFAULT_TIMEOUT = 10
|
|
23
|
+
DEFAULT_RETRIES = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LLMClientError(RuntimeError):
|
|
27
|
+
"""Raised when the Groq API call fails after all retries."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def groq_complete(
|
|
31
|
+
system: str,
|
|
32
|
+
user: str,
|
|
33
|
+
model: str = DEFAULT_MODEL,
|
|
34
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
35
|
+
retries: int = DEFAULT_RETRIES,
|
|
36
|
+
api_key: Optional[str] = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Send a chat completion request to Groq and return the response text.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
system: System prompt (role definition / instructions).
|
|
43
|
+
user: User message (the actual task / input).
|
|
44
|
+
model: Groq model identifier. Default: llama3-8b-8192.
|
|
45
|
+
timeout: Request timeout in seconds.
|
|
46
|
+
retries: Number of retry attempts on transient errors.
|
|
47
|
+
api_key: Override GROQ_API_KEY env var (optional).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The model's response as a plain string.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
LLMClientError: If GROQ_API_KEY is not set or all retries fail.
|
|
54
|
+
"""
|
|
55
|
+
key = api_key or os.environ.get("GROQ_API_KEY", "")
|
|
56
|
+
if not key:
|
|
57
|
+
raise LLMClientError(
|
|
58
|
+
"GROQ_API_KEY is not set. "
|
|
59
|
+
"Export it with: export GROQ_API_KEY=gsk_... "
|
|
60
|
+
"or set it in your .env file."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
headers = {
|
|
64
|
+
"Authorization": f"Bearer {key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
}
|
|
67
|
+
payload = {
|
|
68
|
+
"model": model,
|
|
69
|
+
"messages": [
|
|
70
|
+
{"role": "system", "content": system},
|
|
71
|
+
{"role": "user", "content": user},
|
|
72
|
+
],
|
|
73
|
+
"temperature": 0.2,
|
|
74
|
+
"max_tokens": 512,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
last_error: Optional[Exception] = None
|
|
78
|
+
for attempt in range(retries + 1):
|
|
79
|
+
try:
|
|
80
|
+
resp = requests.post(
|
|
81
|
+
GROQ_API_URL,
|
|
82
|
+
headers=headers,
|
|
83
|
+
json=payload,
|
|
84
|
+
timeout=timeout,
|
|
85
|
+
)
|
|
86
|
+
resp.raise_for_status()
|
|
87
|
+
data = resp.json()
|
|
88
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
89
|
+
except requests.exceptions.Timeout as exc:
|
|
90
|
+
last_error = exc
|
|
91
|
+
if attempt < retries:
|
|
92
|
+
time.sleep(1.0 * (attempt + 1))
|
|
93
|
+
except requests.exceptions.HTTPError as exc:
|
|
94
|
+
# 429 rate limit — back off and retry
|
|
95
|
+
if resp.status_code == 429 and attempt < retries:
|
|
96
|
+
time.sleep(2.0 * (attempt + 1))
|
|
97
|
+
last_error = exc
|
|
98
|
+
else:
|
|
99
|
+
raise LLMClientError(
|
|
100
|
+
f"Groq API returned HTTP {resp.status_code}: {resp.text[:200]}"
|
|
101
|
+
) from exc
|
|
102
|
+
except (KeyError, ValueError, json.JSONDecodeError) as exc:
|
|
103
|
+
raise LLMClientError(
|
|
104
|
+
f"Unexpected Groq response format: {exc}"
|
|
105
|
+
) from exc
|
|
106
|
+
|
|
107
|
+
raise LLMClientError(
|
|
108
|
+
f"Groq request failed after {retries + 1} attempts: {last_error}"
|
|
109
|
+
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SmartFailureAnalyzer — AI-powered pytest failure diagnostics.
|
|
3
|
+
|
|
4
|
+
Hooks into pytest's report pipeline and analyzes FAILED tests using Groq.
|
|
5
|
+
Prints a structured diagnosis to the console after each failure.
|
|
6
|
+
|
|
7
|
+
IMPORTANT:
|
|
8
|
+
- Never modifies test results or retry logic
|
|
9
|
+
- Never changes the failure status of any test
|
|
10
|
+
- Only activated on explicit opt-in (add to conftest.py)
|
|
11
|
+
- Requires GROQ_API_KEY — silently skips analysis if not set
|
|
12
|
+
|
|
13
|
+
Setup (conftest.py):
|
|
14
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
15
|
+
pytest_plugins = [SmartFailureAnalyzer()] # pass an instance
|
|
16
|
+
|
|
17
|
+
Or register via pytest plugin list:
|
|
18
|
+
# conftest.py
|
|
19
|
+
from fixtureforge.ai.failure_analyzer import SmartFailureAnalyzer
|
|
20
|
+
def pytest_configure(config):
|
|
21
|
+
config.pluginmanager.register(SmartFailureAnalyzer(), "fixtureforge-analyzer")
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from ._audit_log import log_event
|
|
31
|
+
from ._groq_client import LLMClientError, groq_complete
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Terminal encoding safety
|
|
35
|
+
# Detect if the terminal supports Unicode box-drawing characters.
|
|
36
|
+
# Windows cmd / PowerShell with cp1255 / cp1252 encoding cannot render them.
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _supports_unicode() -> bool:
|
|
40
|
+
enc = getattr(sys.stdout, "encoding", None) or ""
|
|
41
|
+
return enc.lower().replace("-", "") in ("utf8", "utf8sig")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_USE_UNICODE = _supports_unicode()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _box(title: str, lines: list[str]) -> str:
|
|
48
|
+
"""Render a labeled box. ASCII-safe on non-UTF-8 terminals."""
|
|
49
|
+
if _USE_UNICODE:
|
|
50
|
+
width = max(len(title) + 4, max((len(l) for l in lines), default=0) + 4, 50)
|
|
51
|
+
top = f"\u2554{'=' * (width - 2)}\u2557"
|
|
52
|
+
header = f"\u2551 {title:<{width - 4}} \u2551"
|
|
53
|
+
sep = f"\u2560{'-' * (width - 2)}\u2563"
|
|
54
|
+
bottom = f"\u255a{'=' * (width - 2)}\u255d"
|
|
55
|
+
body = "\n".join(f"\u2551 {l:<{width - 4}} \u2551" for l in lines)
|
|
56
|
+
return "\n".join([top, header, sep, body, bottom])
|
|
57
|
+
else:
|
|
58
|
+
width = max(len(title) + 4, max((len(l) for l in lines), default=0) + 4, 50)
|
|
59
|
+
border = "+" + "=" * (width - 2) + "+"
|
|
60
|
+
divider = "+" + "-" * (width - 2) + "+"
|
|
61
|
+
header = f"| {title:<{width - 4}} |"
|
|
62
|
+
body = "\n".join(f"| {l:<{width - 4}} |" for l in lines)
|
|
63
|
+
return "\n".join([border, header, divider, body, border])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Groq prompt
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
_SYSTEM_PROMPT = (
|
|
71
|
+
"You are a senior QA engineer and Python expert. "
|
|
72
|
+
"Analyze test failures and provide concise, actionable diagnostics. "
|
|
73
|
+
"Return ONLY valid JSON — no markdown, no explanation outside the JSON. "
|
|
74
|
+
"Format: {"
|
|
75
|
+
'"explanation": "one sentence describing what went wrong", '
|
|
76
|
+
'"likely_cause": "root cause in plain English", '
|
|
77
|
+
'"suggested_fix": "specific code-level action to resolve it"'
|
|
78
|
+
"}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
_TAIL_LINES = 20 # number of traceback lines to send
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# SmartFailureAnalyzer plugin
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
class SmartFailureAnalyzer:
|
|
89
|
+
"""
|
|
90
|
+
pytest plugin that analyzes FAILED tests with Groq AI.
|
|
91
|
+
|
|
92
|
+
Behavior:
|
|
93
|
+
- Triggers only when a test's call phase fails
|
|
94
|
+
- Sends: test ID + exception type + exception message + last 20 traceback lines
|
|
95
|
+
- Prints a structured analysis block to stdout
|
|
96
|
+
- Logs every interaction to .fixtureforge_ai_log.jsonl
|
|
97
|
+
- NEVER modifies report.passed or any test state
|
|
98
|
+
|
|
99
|
+
Registration (conftest.py):
|
|
100
|
+
from fixtureforge.ai import SmartFailureAnalyzer
|
|
101
|
+
def pytest_configure(config):
|
|
102
|
+
config.pluginmanager.register(SmartFailureAnalyzer(), "forge-analyzer")
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, api_key: Optional[str] = None) -> None:
|
|
106
|
+
self._api_key = api_key
|
|
107
|
+
self._analyzed: int = 0
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# pytest hook
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def pytest_runtest_logreport(self, report: Any) -> None:
|
|
114
|
+
"""Called by pytest after each test phase (setup / call / teardown)."""
|
|
115
|
+
if report.when != "call" or not report.failed:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Skip if GROQ_API_KEY is not available — never block the test run
|
|
119
|
+
key = self._api_key or os.environ.get("GROQ_API_KEY", "")
|
|
120
|
+
if not key:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
self._analyze(report, api_key=key)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Analysis
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _extract_failure_info(self, report: Any) -> dict[str, str]:
|
|
130
|
+
"""Pull structured info out of a pytest report object."""
|
|
131
|
+
nodeid = getattr(report, "nodeid", "unknown_test")
|
|
132
|
+
longrepr = report.longrepr
|
|
133
|
+
|
|
134
|
+
# longrepr can be a string, tuple, or ExceptionInfo object
|
|
135
|
+
if isinstance(longrepr, str):
|
|
136
|
+
full_tb = longrepr
|
|
137
|
+
exc_type = "UnknownError"
|
|
138
|
+
exc_msg = longrepr[:300]
|
|
139
|
+
elif isinstance(longrepr, tuple):
|
|
140
|
+
# (path, lineno, message) format from some pytest versions
|
|
141
|
+
full_tb = "\n".join(str(p) for p in longrepr)
|
|
142
|
+
exc_type = "Error"
|
|
143
|
+
exc_msg = str(longrepr[-1])[:300]
|
|
144
|
+
else:
|
|
145
|
+
# ExceptionInfo / ReprExceptionInfo
|
|
146
|
+
full_tb = str(longrepr)
|
|
147
|
+
lines = full_tb.splitlines()
|
|
148
|
+
exc_type = "UnknownError"
|
|
149
|
+
exc_msg = ""
|
|
150
|
+
# find the last "E " line — that's the exception message
|
|
151
|
+
for line in reversed(lines):
|
|
152
|
+
stripped = line.strip()
|
|
153
|
+
if stripped.startswith("E "):
|
|
154
|
+
exc_msg = stripped[2:].strip()
|
|
155
|
+
break
|
|
156
|
+
# find exception class name
|
|
157
|
+
for line in reversed(lines):
|
|
158
|
+
for keyword in ("Error", "Exception", "Failure", "assert"):
|
|
159
|
+
if keyword in line:
|
|
160
|
+
exc_type = line.strip()[:80]
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
# last N lines of traceback (avoid huge prompts)
|
|
164
|
+
tail_lines = full_tb.splitlines()[-_TAIL_LINES:]
|
|
165
|
+
tail = "\n".join(tail_lines)
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
"nodeid": nodeid,
|
|
169
|
+
"exc_type": exc_type,
|
|
170
|
+
"exc_msg": exc_msg,
|
|
171
|
+
"tail": tail,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def _call_groq(self, info: dict[str, str], api_key: str) -> dict[str, str]:
|
|
175
|
+
"""Send failure info to Groq and parse structured JSON response."""
|
|
176
|
+
user_msg = (
|
|
177
|
+
f"Test: {info['nodeid']}\n"
|
|
178
|
+
f"Error type: {info['exc_type']}\n"
|
|
179
|
+
f"Error message: {info['exc_msg']}\n"
|
|
180
|
+
f"Traceback (last {_TAIL_LINES} lines):\n{info['tail']}"
|
|
181
|
+
)
|
|
182
|
+
raw = groq_complete(
|
|
183
|
+
system=_SYSTEM_PROMPT,
|
|
184
|
+
user=user_msg,
|
|
185
|
+
api_key=api_key,
|
|
186
|
+
)
|
|
187
|
+
# strip markdown fences if model ignored instructions
|
|
188
|
+
clean = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
|
189
|
+
parsed = json.loads(clean)
|
|
190
|
+
return {
|
|
191
|
+
"explanation": str(parsed.get("explanation", "")),
|
|
192
|
+
"likely_cause": str(parsed.get("likely_cause", "")),
|
|
193
|
+
"suggested_fix": str(parsed.get("suggested_fix", "")),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
def _print_analysis(self, info: dict[str, str], analysis: dict[str, str]) -> None:
|
|
197
|
+
"""Print the analysis block to stdout — encoding-safe."""
|
|
198
|
+
label = "FixtureForge AI Analysis"
|
|
199
|
+
lines = [
|
|
200
|
+
f"Test : {info['nodeid']}",
|
|
201
|
+
"",
|
|
202
|
+
f"Cause : {analysis['likely_cause']}",
|
|
203
|
+
"",
|
|
204
|
+
f"Detail : {analysis['explanation']}",
|
|
205
|
+
"",
|
|
206
|
+
f"Fix : {analysis['suggested_fix']}",
|
|
207
|
+
]
|
|
208
|
+
box = _box(label, lines)
|
|
209
|
+
# Write via sys.stdout with explicit encoding fallback
|
|
210
|
+
try:
|
|
211
|
+
print("\n" + box + "\n", flush=True)
|
|
212
|
+
except UnicodeEncodeError:
|
|
213
|
+
safe = (box
|
|
214
|
+
.encode(sys.stdout.encoding or "ascii", errors="replace")
|
|
215
|
+
.decode(sys.stdout.encoding or "ascii"))
|
|
216
|
+
print("\n" + safe + "\n", flush=True)
|
|
217
|
+
|
|
218
|
+
def _analyze(self, report: Any, api_key: str) -> None:
|
|
219
|
+
"""Full analysis pipeline for one failed test."""
|
|
220
|
+
info = self._extract_failure_info(report)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
analysis = self._call_groq(info, api_key=api_key)
|
|
224
|
+
except (LLMClientError, json.JSONDecodeError, KeyError) as exc:
|
|
225
|
+
log_event("failure_analysis_error", {
|
|
226
|
+
"nodeid": info["nodeid"],
|
|
227
|
+
"error": str(exc),
|
|
228
|
+
})
|
|
229
|
+
return # silently skip — never interrupt the test session
|
|
230
|
+
|
|
231
|
+
self._print_analysis(info, analysis)
|
|
232
|
+
self._analyzed += 1
|
|
233
|
+
|
|
234
|
+
log_event("failure_analysis", {
|
|
235
|
+
"nodeid": info["nodeid"],
|
|
236
|
+
"exc_type": info["exc_type"],
|
|
237
|
+
"explanation": analysis["explanation"],
|
|
238
|
+
"likely_cause": analysis["likely_cause"],
|
|
239
|
+
"suggested_fix": analysis["suggested_fix"],
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
# ------------------------------------------------------------------
|
|
243
|
+
# Summary (optional hook)
|
|
244
|
+
# ------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def pytest_terminal_summary(self, terminalreporter: Any, exitstatus: int) -> None:
|
|
247
|
+
"""Append a one-line summary at the end of the pytest session."""
|
|
248
|
+
if self._analyzed > 0:
|
|
249
|
+
terminalreporter.write_sep(
|
|
250
|
+
"-",
|
|
251
|
+
f"FixtureForge AI: analyzed {self._analyzed} failure(s) -- "
|
|
252
|
+
f"see .fixtureforge_ai_log.jsonl for full audit trail",
|
|
253
|
+
)
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
assert_semantic_match — semantic validation for AI-generated or natural-language outputs.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
Local (default, use_llm=False):
|
|
6
|
+
Cosine similarity on TF-IDF vectors built from stdlib only.
|
|
7
|
+
Zero API calls. Zero cost. Always available offline.
|
|
8
|
+
|
|
9
|
+
LLM (use_llm=True):
|
|
10
|
+
Sends actual + expected_intent to Groq for a structured judgment.
|
|
11
|
+
Requires GROQ_API_KEY. Every call is logged to .fixtureforge_ai_log.jsonl.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
from fixtureforge.ai import assert_semantic_match
|
|
15
|
+
|
|
16
|
+
# Local (no API key needed)
|
|
17
|
+
result = assert_semantic_match(
|
|
18
|
+
actual="The product was delivered on time and in perfect condition.",
|
|
19
|
+
expected_intent="positive delivery experience",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# LLM-powered (explicit opt-in)
|
|
23
|
+
result = assert_semantic_match(
|
|
24
|
+
actual=chatbot_response,
|
|
25
|
+
expected_intent="politely decline the refund request",
|
|
26
|
+
use_llm=True,
|
|
27
|
+
threshold=0.8,
|
|
28
|
+
)
|
|
29
|
+
# Raises SemanticAssertionError if result.passed is False
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
from collections import Counter
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Optional
|
|
38
|
+
|
|
39
|
+
from ._audit_log import log_event
|
|
40
|
+
from ._groq_client import LLMClientError, groq_complete
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Public types
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class SemanticResult:
|
|
48
|
+
"""Return value of assert_semantic_match."""
|
|
49
|
+
passed: bool
|
|
50
|
+
score: float # 0.0 – 1.0
|
|
51
|
+
reason: str # human-readable explanation
|
|
52
|
+
method: str # "local" or "llm"
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
status = "PASS" if self.passed else "FAIL"
|
|
56
|
+
return (
|
|
57
|
+
f"SemanticResult({status} | score={self.score:.2f} "
|
|
58
|
+
f"| method={self.method} | reason={self.reason!r})"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SemanticAssertionError(AssertionError):
|
|
63
|
+
"""Raised when assert_semantic_match fails."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, result: SemanticResult, actual: str, expected_intent: str) -> None:
|
|
66
|
+
self.result = result
|
|
67
|
+
self.actual = actual
|
|
68
|
+
self.expected_intent = expected_intent
|
|
69
|
+
super().__init__(
|
|
70
|
+
f"\n\nSemantic assertion failed.\n"
|
|
71
|
+
f" Expected intent : {expected_intent!r}\n"
|
|
72
|
+
f" Actual output : {actual!r}\n"
|
|
73
|
+
f" Score : {result.score:.2f} (threshold not met)\n"
|
|
74
|
+
f" Method : {result.method}\n"
|
|
75
|
+
f" Reason : {result.reason}\n"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Local cosine similarity (stdlib only — no external dependencies)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _tokenize(text: str) -> Counter:
|
|
84
|
+
"""Lowercase, split on whitespace, return token frequency counter."""
|
|
85
|
+
tokens = text.lower().split()
|
|
86
|
+
# strip punctuation from token edges
|
|
87
|
+
cleaned = [t.strip(".,!?;:\"'()-[]{}") for t in tokens]
|
|
88
|
+
return Counter(t for t in cleaned if t)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _cosine_similarity(text_a: str, text_b: str) -> float:
|
|
92
|
+
"""
|
|
93
|
+
TF-IDF cosine similarity between two strings using stdlib only.
|
|
94
|
+
Returns a value in [0.0, 1.0].
|
|
95
|
+
"""
|
|
96
|
+
vec_a = _tokenize(text_a)
|
|
97
|
+
vec_b = _tokenize(text_b)
|
|
98
|
+
|
|
99
|
+
if not vec_a or not vec_b:
|
|
100
|
+
return 0.0
|
|
101
|
+
|
|
102
|
+
# dot product over shared tokens
|
|
103
|
+
shared = set(vec_a) & set(vec_b)
|
|
104
|
+
dot = sum(vec_a[w] * vec_b[w] for w in shared)
|
|
105
|
+
|
|
106
|
+
# magnitudes
|
|
107
|
+
mag_a = math.sqrt(sum(v * v for v in vec_a.values()))
|
|
108
|
+
mag_b = math.sqrt(sum(v * v for v in vec_b.values()))
|
|
109
|
+
|
|
110
|
+
if mag_a == 0.0 or mag_b == 0.0:
|
|
111
|
+
return 0.0
|
|
112
|
+
|
|
113
|
+
return dot / (mag_a * mag_b)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _local_assert(actual: str, expected_intent: str, threshold: float) -> SemanticResult:
|
|
117
|
+
score = _cosine_similarity(actual, expected_intent)
|
|
118
|
+
passed = score >= threshold
|
|
119
|
+
reason = (
|
|
120
|
+
f"Cosine similarity {score:.2f} >= threshold {threshold:.2f}"
|
|
121
|
+
if passed
|
|
122
|
+
else f"Cosine similarity {score:.2f} < threshold {threshold:.2f}"
|
|
123
|
+
)
|
|
124
|
+
return SemanticResult(passed=passed, score=score, reason=reason, method="local")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# LLM-powered assertion via Groq
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
_LLM_SYSTEM = (
|
|
132
|
+
"You are a test validation judge. "
|
|
133
|
+
"Given an actual output and an expected intent, "
|
|
134
|
+
"return ONLY valid JSON with no markdown, no explanation. "
|
|
135
|
+
'Format: {"passed": bool, "score": float, "reason": str}'
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _llm_assert(
|
|
140
|
+
actual: str,
|
|
141
|
+
expected_intent: str,
|
|
142
|
+
threshold: float,
|
|
143
|
+
api_key: Optional[str],
|
|
144
|
+
) -> SemanticResult:
|
|
145
|
+
user_msg = (
|
|
146
|
+
f"Expected intent: {expected_intent}\n"
|
|
147
|
+
f"Actual output: {actual}\n"
|
|
148
|
+
f"Score threshold: {threshold}\n"
|
|
149
|
+
"Does the actual output satisfy the expected intent? "
|
|
150
|
+
"Return JSON only."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
raw = groq_complete(system=_LLM_SYSTEM, user=user_msg, api_key=api_key)
|
|
155
|
+
# strip markdown fences if model ignored instructions
|
|
156
|
+
raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```").strip()
|
|
157
|
+
parsed = json.loads(raw)
|
|
158
|
+
|
|
159
|
+
score = float(parsed.get("score", 0.0))
|
|
160
|
+
passed = bool(parsed.get("passed", score >= threshold))
|
|
161
|
+
reason = str(parsed.get("reason", ""))
|
|
162
|
+
|
|
163
|
+
log_event("semantic_assert_llm", {
|
|
164
|
+
"actual": actual[:200],
|
|
165
|
+
"expected_intent": expected_intent[:200],
|
|
166
|
+
"threshold": threshold,
|
|
167
|
+
"score": score,
|
|
168
|
+
"passed": passed,
|
|
169
|
+
"reason": reason,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return SemanticResult(passed=passed, score=score, reason=reason, method="llm")
|
|
173
|
+
|
|
174
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
175
|
+
# Graceful degradation: fall back to local on parse error
|
|
176
|
+
log_event("semantic_assert_llm_parse_error", {"error": str(exc), "raw": raw[:300]})
|
|
177
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
178
|
+
result.reason = f"[LLM response unparseable, fell back to local] {result.reason}"
|
|
179
|
+
result.method = "local_fallback"
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
except LLMClientError as exc:
|
|
183
|
+
# No API key or network error — fall back to local silently
|
|
184
|
+
log_event("semantic_assert_llm_error", {"error": str(exc)})
|
|
185
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
186
|
+
result.reason = f"[LLM unavailable, fell back to local] {result.reason}"
|
|
187
|
+
result.method = "local_fallback"
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Public API
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def assert_semantic_match(
|
|
196
|
+
actual: str,
|
|
197
|
+
expected_intent: str,
|
|
198
|
+
use_llm: bool = False,
|
|
199
|
+
threshold: float = 0.75,
|
|
200
|
+
api_key: Optional[str] = None,
|
|
201
|
+
raise_on_failure: bool = True,
|
|
202
|
+
) -> SemanticResult:
|
|
203
|
+
"""
|
|
204
|
+
Assert that `actual` semantically matches `expected_intent`.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
actual: The string to validate (e.g. chatbot response, generated text).
|
|
208
|
+
expected_intent: Natural language description of what the output should convey.
|
|
209
|
+
use_llm: If True, use Groq for a more nuanced judgment.
|
|
210
|
+
Requires GROQ_API_KEY. Default: False (local cosine similarity).
|
|
211
|
+
threshold: Minimum score to pass. Range: [0.0, 1.0]. Default: 0.75.
|
|
212
|
+
api_key: Override GROQ_API_KEY env var (optional).
|
|
213
|
+
raise_on_failure: If True (default), raise SemanticAssertionError on failure.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
SemanticResult(passed, score, reason, method)
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
SemanticAssertionError: If the assertion fails and raise_on_failure=True.
|
|
220
|
+
|
|
221
|
+
Examples:
|
|
222
|
+
# Local — zero cost, always available
|
|
223
|
+
assert_semantic_match(
|
|
224
|
+
actual="Great experience, delivered next day!",
|
|
225
|
+
expected_intent="positive customer review",
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# LLM — richer judgment, requires API key
|
|
229
|
+
result = assert_semantic_match(
|
|
230
|
+
actual=model_output,
|
|
231
|
+
expected_intent="professional refusal of the request",
|
|
232
|
+
use_llm=True,
|
|
233
|
+
threshold=0.8,
|
|
234
|
+
)
|
|
235
|
+
print(result.score, result.reason)
|
|
236
|
+
"""
|
|
237
|
+
if not isinstance(actual, str) or not isinstance(expected_intent, str):
|
|
238
|
+
raise TypeError(
|
|
239
|
+
f"assert_semantic_match expects str arguments, "
|
|
240
|
+
f"got actual={type(actual).__name__}, "
|
|
241
|
+
f"expected_intent={type(expected_intent).__name__}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if use_llm:
|
|
245
|
+
result = _llm_assert(actual, expected_intent, threshold, api_key)
|
|
246
|
+
else:
|
|
247
|
+
result = _local_assert(actual, expected_intent, threshold)
|
|
248
|
+
|
|
249
|
+
if not result.passed and raise_on_failure:
|
|
250
|
+
raise SemanticAssertionError(result, actual, expected_intent)
|
|
251
|
+
|
|
252
|
+
return result
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|