prellm 0.1.12__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.
- prellm-0.1.12/LICENSE +21 -0
- prellm-0.1.12/PKG-INFO +141 -0
- prellm-0.1.12/README.md +112 -0
- prellm-0.1.12/promptguard/__init__.py +19 -0
- prellm-0.1.12/promptguard/analyzers/__init__.py +1 -0
- prellm-0.1.12/promptguard/analyzers/bias_detector.py +96 -0
- prellm-0.1.12/promptguard/analyzers/context_engine.py +90 -0
- prellm-0.1.12/promptguard/chains/__init__.py +0 -0
- prellm-0.1.12/promptguard/chains/process_chain.py +226 -0
- prellm-0.1.12/promptguard/cli.py +175 -0
- prellm-0.1.12/promptguard/core.py +171 -0
- prellm-0.1.12/promptguard/models.py +135 -0
- prellm-0.1.12/pyproject.toml +48 -0
prellm-0.1.12/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Softreck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
prellm-0.1.12/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prellm
|
|
3
|
+
Version: 0.1.12
|
|
4
|
+
Summary: PromptGuard - Lightweight LLM prompt middleware for bias detection, standardization, and DevOps process chains via YAML config, supporting context injection and multi-provider orchestration.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: llm,prompt-engineering,bias-detection,devops,litellm
|
|
8
|
+
Author: Softreck
|
|
9
|
+
Author-email: tom@sapletta.com
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Dist: litellm (>=1.40,<2.0)
|
|
21
|
+
Requires-Dist: nltk (>=3.8,<4.0)
|
|
22
|
+
Requires-Dist: pydantic (>=2.0,<3.0)
|
|
23
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
24
|
+
Requires-Dist: textstat (>=0.7,<0.8)
|
|
25
|
+
Requires-Dist: typer[all] (>=0.12,<0.13)
|
|
26
|
+
Project-URL: Repository, https://github.com/softreck/gllm
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# 🛡️ PromptGuard
|
|
30
|
+
|
|
31
|
+
**Lightweight LLM prompt middleware — bias detection, standardization, and DevOps process chains via YAML config.**
|
|
32
|
+
|
|
33
|
+
PromptGuard sits between your application and LLM providers, automatically detecting bias, ambiguity, and dangerous patterns in prompts. It enriches queries with context, validates outputs, and supports multi-step DevOps workflows with approval gates.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Bias & Ambiguity Detection** — regex + NLTK patterns for PL/EN, with DevOps-specific guardrails
|
|
38
|
+
- **YAML-Driven Config** — declarative rules, clarification templates, model fallbacks
|
|
39
|
+
- **100+ LLM Models** — via LiteLLM proxy (OpenAI, Anthropic, Llama, Mistral, etc.)
|
|
40
|
+
- **DevOps Process Chains** — multi-step workflows with approval gates, rollback, and audit trails
|
|
41
|
+
- **Context Injection** — auto-enrich prompts with env vars, git info, system state
|
|
42
|
+
- **Type-Safe Outputs** — Pydantic v2 validated responses
|
|
43
|
+
- **Lightweight** — <50MB, 5 dependencies, async-first
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Install
|
|
49
|
+
pip install promptguard
|
|
50
|
+
|
|
51
|
+
# Generate config
|
|
52
|
+
promptguard init --devops -o rules.yaml
|
|
53
|
+
|
|
54
|
+
# Analyze a query (no LLM call)
|
|
55
|
+
promptguard analyze "Deploy to production" --config rules.yaml
|
|
56
|
+
|
|
57
|
+
# Run with LLM
|
|
58
|
+
promptguard run "Zdeployuj na staging" --config rules.yaml --model gpt-4o-mini
|
|
59
|
+
|
|
60
|
+
# Execute a process chain
|
|
61
|
+
promptguard process deploy.yaml --guard-config rules.yaml --env production
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Python API
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from promptguard import PromptGuard, ProcessChain
|
|
68
|
+
|
|
69
|
+
# Simple query
|
|
70
|
+
guard = PromptGuard("rules.yaml")
|
|
71
|
+
result = await guard("Deploy to production", model="gpt-4o-mini")
|
|
72
|
+
print(result.clarified) # True — detected missing context
|
|
73
|
+
print(result.content) # Enriched response
|
|
74
|
+
|
|
75
|
+
# Process chain
|
|
76
|
+
chain = ProcessChain("deploy.yaml")
|
|
77
|
+
result = await chain.execute(env="production", dry_run=True)
|
|
78
|
+
for step in result.steps:
|
|
79
|
+
print(f"{step.step_name}: {step.status}")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
### rules.yaml
|
|
85
|
+
```yaml
|
|
86
|
+
bias_patterns:
|
|
87
|
+
- regex: "(deploy|zdeployuj)\\s+(na|to)\\s+(prod|production)"
|
|
88
|
+
action: clarify
|
|
89
|
+
severity: critical
|
|
90
|
+
description: "Production deployment — requires context"
|
|
91
|
+
|
|
92
|
+
clarify_template: "[KONTEKST]: Podaj szczegóły dla: {query}"
|
|
93
|
+
max_retries: 3
|
|
94
|
+
policy: devops
|
|
95
|
+
|
|
96
|
+
models:
|
|
97
|
+
fallback: ["gpt-4o-mini", "llama3"]
|
|
98
|
+
|
|
99
|
+
context_sources:
|
|
100
|
+
- env: [CLUSTER, NAMESPACE, GIT_SHA]
|
|
101
|
+
- git: [branch, short_sha]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### deploy.yaml (Process Chain)
|
|
105
|
+
```yaml
|
|
106
|
+
process: deploy-production
|
|
107
|
+
steps:
|
|
108
|
+
- name: pre-check
|
|
109
|
+
prompt: "Check readiness of {CLUSTER}"
|
|
110
|
+
approval: auto
|
|
111
|
+
- name: deploy
|
|
112
|
+
prompt: "Rolling deploy to {CLUSTER}/{NAMESPACE}"
|
|
113
|
+
approval: manual
|
|
114
|
+
rollback: true
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Architecture
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
User Query → BiasDetector → ContextEngine → Enrichment → LiteLLM → Pydantic Validation → Response
|
|
121
|
+
↑
|
|
122
|
+
ProcessChain → Approval Gates → Audit Trail
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
git clone https://github.com/softreck/promptguard
|
|
129
|
+
cd promptguard
|
|
130
|
+
poetry install
|
|
131
|
+
poetry run pytest
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
|
137
|
+
|
|
138
|
+
## Author
|
|
139
|
+
|
|
140
|
+
Created by **Tom Sapletta** - [tom@sapletta.com](mailto:tom@sapletta.com)
|
|
141
|
+
|
prellm-0.1.12/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# 🛡️ PromptGuard
|
|
2
|
+
|
|
3
|
+
**Lightweight LLM prompt middleware — bias detection, standardization, and DevOps process chains via YAML config.**
|
|
4
|
+
|
|
5
|
+
PromptGuard sits between your application and LLM providers, automatically detecting bias, ambiguity, and dangerous patterns in prompts. It enriches queries with context, validates outputs, and supports multi-step DevOps workflows with approval gates.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Bias & Ambiguity Detection** — regex + NLTK patterns for PL/EN, with DevOps-specific guardrails
|
|
10
|
+
- **YAML-Driven Config** — declarative rules, clarification templates, model fallbacks
|
|
11
|
+
- **100+ LLM Models** — via LiteLLM proxy (OpenAI, Anthropic, Llama, Mistral, etc.)
|
|
12
|
+
- **DevOps Process Chains** — multi-step workflows with approval gates, rollback, and audit trails
|
|
13
|
+
- **Context Injection** — auto-enrich prompts with env vars, git info, system state
|
|
14
|
+
- **Type-Safe Outputs** — Pydantic v2 validated responses
|
|
15
|
+
- **Lightweight** — <50MB, 5 dependencies, async-first
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Install
|
|
21
|
+
pip install promptguard
|
|
22
|
+
|
|
23
|
+
# Generate config
|
|
24
|
+
promptguard init --devops -o rules.yaml
|
|
25
|
+
|
|
26
|
+
# Analyze a query (no LLM call)
|
|
27
|
+
promptguard analyze "Deploy to production" --config rules.yaml
|
|
28
|
+
|
|
29
|
+
# Run with LLM
|
|
30
|
+
promptguard run "Zdeployuj na staging" --config rules.yaml --model gpt-4o-mini
|
|
31
|
+
|
|
32
|
+
# Execute a process chain
|
|
33
|
+
promptguard process deploy.yaml --guard-config rules.yaml --env production
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Python API
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from promptguard import PromptGuard, ProcessChain
|
|
40
|
+
|
|
41
|
+
# Simple query
|
|
42
|
+
guard = PromptGuard("rules.yaml")
|
|
43
|
+
result = await guard("Deploy to production", model="gpt-4o-mini")
|
|
44
|
+
print(result.clarified) # True — detected missing context
|
|
45
|
+
print(result.content) # Enriched response
|
|
46
|
+
|
|
47
|
+
# Process chain
|
|
48
|
+
chain = ProcessChain("deploy.yaml")
|
|
49
|
+
result = await chain.execute(env="production", dry_run=True)
|
|
50
|
+
for step in result.steps:
|
|
51
|
+
print(f"{step.step_name}: {step.status}")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
### rules.yaml
|
|
57
|
+
```yaml
|
|
58
|
+
bias_patterns:
|
|
59
|
+
- regex: "(deploy|zdeployuj)\\s+(na|to)\\s+(prod|production)"
|
|
60
|
+
action: clarify
|
|
61
|
+
severity: critical
|
|
62
|
+
description: "Production deployment — requires context"
|
|
63
|
+
|
|
64
|
+
clarify_template: "[KONTEKST]: Podaj szczegóły dla: {query}"
|
|
65
|
+
max_retries: 3
|
|
66
|
+
policy: devops
|
|
67
|
+
|
|
68
|
+
models:
|
|
69
|
+
fallback: ["gpt-4o-mini", "llama3"]
|
|
70
|
+
|
|
71
|
+
context_sources:
|
|
72
|
+
- env: [CLUSTER, NAMESPACE, GIT_SHA]
|
|
73
|
+
- git: [branch, short_sha]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### deploy.yaml (Process Chain)
|
|
77
|
+
```yaml
|
|
78
|
+
process: deploy-production
|
|
79
|
+
steps:
|
|
80
|
+
- name: pre-check
|
|
81
|
+
prompt: "Check readiness of {CLUSTER}"
|
|
82
|
+
approval: auto
|
|
83
|
+
- name: deploy
|
|
84
|
+
prompt: "Rolling deploy to {CLUSTER}/{NAMESPACE}"
|
|
85
|
+
approval: manual
|
|
86
|
+
rollback: true
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Architecture
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
User Query → BiasDetector → ContextEngine → Enrichment → LiteLLM → Pydantic Validation → Response
|
|
93
|
+
↑
|
|
94
|
+
ProcessChain → Approval Gates → Audit Trail
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/softreck/promptguard
|
|
101
|
+
cd promptguard
|
|
102
|
+
poetry install
|
|
103
|
+
poetry run pytest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## License
|
|
107
|
+
|
|
108
|
+
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
|
109
|
+
|
|
110
|
+
## Author
|
|
111
|
+
|
|
112
|
+
Created by **Tom Sapletta** - [tom@sapletta.com](mailto:tom@sapletta.com)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""PromptGuard — Lightweight LLM prompt middleware for bias detection, standardization, and DevOps process chains."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.12"
|
|
4
|
+
|
|
5
|
+
from promptguard.core import PromptGuard
|
|
6
|
+
from promptguard.models import GuardResponse, GuardConfig, AnalysisResult
|
|
7
|
+
from promptguard.chains.process_chain import ProcessChain
|
|
8
|
+
from promptguard.analyzers.bias_detector import BiasDetector
|
|
9
|
+
from promptguard.analyzers.context_engine import ContextEngine
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PromptGuard",
|
|
13
|
+
"ProcessChain",
|
|
14
|
+
"GuardResponse",
|
|
15
|
+
"GuardConfig",
|
|
16
|
+
"AnalysisResult",
|
|
17
|
+
"BiasDetector",
|
|
18
|
+
"ContextEngine",
|
|
19
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""BiasDetector — scans queries for bias patterns, ambiguity, and readability issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from promptguard.models import AnalysisResult, BiasPattern
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Default bias/ambiguity patterns for Polish & English DevOps context
|
|
12
|
+
DEFAULT_PATTERNS: list[dict[str, str]] = [
|
|
13
|
+
{"regex": r"(zawsze|always)\s+\w+", "action": "clarify", "severity": "medium", "description": "Absolute quantifier"},
|
|
14
|
+
{"regex": r"(tylko|only|just)\s+\w+", "action": "clarify", "severity": "low", "description": "Exclusive quantifier"},
|
|
15
|
+
{"regex": r"(głupi|stupid|dumb)\s+\w+", "action": "flag", "severity": "high", "description": "Derogatory language"},
|
|
16
|
+
{"regex": r"(zdeployuj|deploy|push)\s+(na|to)\s+(prod|production)", "action": "clarify", "severity": "critical",
|
|
17
|
+
"description": "Production deployment without context"},
|
|
18
|
+
{"regex": r"(usuń|delete|drop|remove)\s+(baz[ęa]|database|db|table)", "action": "clarify", "severity": "critical",
|
|
19
|
+
"description": "Destructive DB operation"},
|
|
20
|
+
{"regex": r"(restart|reboot|kill)\s+(server|service|pod|container)", "action": "clarify", "severity": "high",
|
|
21
|
+
"description": "Service disruption command"},
|
|
22
|
+
{"regex": r"(migrate|migruj)\s+.*(?:prod|production)", "action": "clarify", "severity": "critical",
|
|
23
|
+
"description": "Production migration"},
|
|
24
|
+
{"regex": r"(skaluj|scale)\s+(down|up|to\s+\d+)", "action": "clarify", "severity": "high",
|
|
25
|
+
"description": "Scaling operation"},
|
|
26
|
+
{"regex": r"(zmień|change|update)\s+(config|konfigurację|env|secret)", "action": "clarify", "severity": "high",
|
|
27
|
+
"description": "Configuration change"},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BiasDetector:
|
|
32
|
+
"""Detects bias, ambiguity, and dangerous patterns in queries.
|
|
33
|
+
|
|
34
|
+
Uses regex patterns (configurable via YAML) + optional NLTK readability scoring.
|
|
35
|
+
Designed for both general prompt safety and DevOps-specific guardrails.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, patterns: list[BiasPattern] | None = None):
|
|
39
|
+
if patterns:
|
|
40
|
+
self.patterns = patterns
|
|
41
|
+
else:
|
|
42
|
+
self.patterns = [BiasPattern(**p) for p in DEFAULT_PATTERNS]
|
|
43
|
+
|
|
44
|
+
self._readability_available = False
|
|
45
|
+
try:
|
|
46
|
+
import textstat # noqa: F401
|
|
47
|
+
self._readability_available = True
|
|
48
|
+
except ImportError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def analyze(self, query: str) -> AnalysisResult:
|
|
52
|
+
"""Analyze a query for bias patterns and ambiguity."""
|
|
53
|
+
detected: list[str] = []
|
|
54
|
+
ambiguity_flags: list[str] = []
|
|
55
|
+
needs_clarify = False
|
|
56
|
+
|
|
57
|
+
for pattern in self.patterns:
|
|
58
|
+
if re.search(pattern.regex, query, re.IGNORECASE):
|
|
59
|
+
detected.append(f"[{pattern.severity}] {pattern.description}")
|
|
60
|
+
if pattern.action == "clarify":
|
|
61
|
+
needs_clarify = True
|
|
62
|
+
ambiguity_flags.append(pattern.description)
|
|
63
|
+
|
|
64
|
+
# Check for very short / context-free queries
|
|
65
|
+
word_count = len(query.split())
|
|
66
|
+
if word_count < 4:
|
|
67
|
+
needs_clarify = True
|
|
68
|
+
ambiguity_flags.append("Query too short — likely missing context")
|
|
69
|
+
|
|
70
|
+
# Check for missing subject/object in DevOps context
|
|
71
|
+
devops_verbs = ["deploy", "zdeployuj", "migrate", "migruj", "scale", "skaluj",
|
|
72
|
+
"restart", "delete", "usuń", "push", "rollback"]
|
|
73
|
+
has_devops_verb = any(v in query.lower() for v in devops_verbs)
|
|
74
|
+
has_target = any(t in query.lower() for t in [
|
|
75
|
+
"staging", "production", "prod", "dev", "test", "cluster", "namespace"])
|
|
76
|
+
if has_devops_verb and not has_target:
|
|
77
|
+
needs_clarify = True
|
|
78
|
+
ambiguity_flags.append("DevOps command without target environment")
|
|
79
|
+
|
|
80
|
+
# Readability score
|
|
81
|
+
readability = None
|
|
82
|
+
if self._readability_available:
|
|
83
|
+
try:
|
|
84
|
+
import textstat
|
|
85
|
+
readability = textstat.flesch_reading_ease(query)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
return AnalysisResult(
|
|
90
|
+
needs_clarify=needs_clarify,
|
|
91
|
+
detected_patterns=detected,
|
|
92
|
+
enriched_query="",
|
|
93
|
+
original_query=query,
|
|
94
|
+
readability_score=readability,
|
|
95
|
+
ambiguity_flags=ambiguity_flags,
|
|
96
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""ContextEngine — gathers runtime context (env vars, git info, system state) for prompt enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContextEngine:
|
|
11
|
+
"""Collects context from environment, git, and system for prompt enrichment.
|
|
12
|
+
|
|
13
|
+
Used by both core PromptGuard (auto-inject context) and ProcessChain (step-level context).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, context_sources: list[dict[str, Any]] | None = None):
|
|
17
|
+
self.sources = context_sources or []
|
|
18
|
+
|
|
19
|
+
def gather(self) -> dict[str, str]:
|
|
20
|
+
"""Gather all configured context into a flat dict."""
|
|
21
|
+
ctx: dict[str, str] = {}
|
|
22
|
+
|
|
23
|
+
for source in self.sources:
|
|
24
|
+
if "env" in source:
|
|
25
|
+
ctx.update(self._gather_env(source["env"]))
|
|
26
|
+
if "git" in source:
|
|
27
|
+
ctx.update(self._gather_git(source["git"]))
|
|
28
|
+
if "system" in source:
|
|
29
|
+
ctx.update(self._gather_system(source["system"]))
|
|
30
|
+
|
|
31
|
+
return ctx
|
|
32
|
+
|
|
33
|
+
def enrich_prompt(self, prompt: str, extra: dict[str, str] | None = None) -> str:
|
|
34
|
+
"""Substitute {VARIABLE} placeholders in a prompt with gathered context."""
|
|
35
|
+
ctx = self.gather()
|
|
36
|
+
if extra:
|
|
37
|
+
ctx.update(extra)
|
|
38
|
+
|
|
39
|
+
enriched = prompt
|
|
40
|
+
for key, value in ctx.items():
|
|
41
|
+
enriched = enriched.replace(f"{{{key}}}", value)
|
|
42
|
+
|
|
43
|
+
return enriched
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def _gather_env(keys: list[str]) -> dict[str, str]:
|
|
47
|
+
result = {}
|
|
48
|
+
for key in keys:
|
|
49
|
+
val = os.environ.get(key, "")
|
|
50
|
+
if val:
|
|
51
|
+
result[key] = val
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _gather_git(fields: list[str]) -> dict[str, str]:
|
|
56
|
+
result = {}
|
|
57
|
+
git_commands = {
|
|
58
|
+
"branch": ["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
59
|
+
"last_commit": ["git", "log", "-1", "--format=%H"],
|
|
60
|
+
"last_commit_msg": ["git", "log", "-1", "--format=%s"],
|
|
61
|
+
"short_sha": ["git", "rev-parse", "--short", "HEAD"],
|
|
62
|
+
"tag": ["git", "describe", "--tags", "--abbrev=0"],
|
|
63
|
+
"remote_url": ["git", "remote", "get-url", "origin"],
|
|
64
|
+
}
|
|
65
|
+
for field in fields:
|
|
66
|
+
cmd = git_commands.get(field)
|
|
67
|
+
if cmd:
|
|
68
|
+
try:
|
|
69
|
+
out = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
|
70
|
+
if out.returncode == 0:
|
|
71
|
+
result[field] = out.stdout.strip()
|
|
72
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
73
|
+
pass
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _gather_system(fields: list[str]) -> dict[str, str]:
|
|
78
|
+
result = {}
|
|
79
|
+
import platform
|
|
80
|
+
system_map = {
|
|
81
|
+
"hostname": platform.node,
|
|
82
|
+
"os": platform.system,
|
|
83
|
+
"arch": platform.machine,
|
|
84
|
+
"python": platform.python_version,
|
|
85
|
+
}
|
|
86
|
+
for field in fields:
|
|
87
|
+
fn = system_map.get(field)
|
|
88
|
+
if fn:
|
|
89
|
+
result[field] = fn()
|
|
90
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""ProcessChain — Multi-step DevOps workflow engine with approval gates and audit trail.
|
|
2
|
+
|
|
3
|
+
Defines workflows as YAML, validates each step through PromptGuard, supports
|
|
4
|
+
manual/auto approval, rollback, and full audit logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from promptguard.analyzers.context_engine import ContextEngine
|
|
18
|
+
from promptguard.core import PromptGuard
|
|
19
|
+
from promptguard.models import (
|
|
20
|
+
ApprovalMode,
|
|
21
|
+
AuditEntry,
|
|
22
|
+
GuardConfig,
|
|
23
|
+
ProcessConfig,
|
|
24
|
+
ProcessResult,
|
|
25
|
+
ProcessStep,
|
|
26
|
+
StepResult,
|
|
27
|
+
StepStatus,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("promptguard.chains")
|
|
31
|
+
|
|
32
|
+
# Type for approval callback: receives step info, returns (approved: bool, approved_by: str)
|
|
33
|
+
ApprovalCallback = Callable[[str, str], Awaitable[tuple[bool, str]]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProcessChain:
|
|
37
|
+
"""Execute multi-step DevOps workflows with PromptGuard validation at each step.
|
|
38
|
+
|
|
39
|
+
Usage:
|
|
40
|
+
chain = ProcessChain("deploy.yaml")
|
|
41
|
+
result = await chain.execute(
|
|
42
|
+
env="production",
|
|
43
|
+
approval_callback=my_slack_approval_fn,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
Each step's prompt goes through PromptGuard's full pipeline (bias detection,
|
|
47
|
+
enrichment, LLM call, validation) before execution.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config_path: str | Path | None = None,
|
|
53
|
+
config: ProcessConfig | None = None,
|
|
54
|
+
guard_config_path: str | Path | None = None,
|
|
55
|
+
guard: PromptGuard | None = None,
|
|
56
|
+
):
|
|
57
|
+
if config:
|
|
58
|
+
self.process_config = config
|
|
59
|
+
elif config_path:
|
|
60
|
+
self.process_config = self._load_process_config(Path(config_path))
|
|
61
|
+
else:
|
|
62
|
+
raise ValueError("Either config_path or config must be provided")
|
|
63
|
+
|
|
64
|
+
self.guard = guard or PromptGuard(config_path=guard_config_path)
|
|
65
|
+
self.context_engine = ContextEngine(self.process_config.context_sources)
|
|
66
|
+
self.audit_log: list[AuditEntry] = []
|
|
67
|
+
self._step_results: dict[str, StepResult] = {}
|
|
68
|
+
|
|
69
|
+
async def execute(
|
|
70
|
+
self,
|
|
71
|
+
extra_context: dict[str, str] | None = None,
|
|
72
|
+
approval_callback: ApprovalCallback | None = None,
|
|
73
|
+
dry_run: bool = False,
|
|
74
|
+
**env_overrides: str,
|
|
75
|
+
) -> ProcessResult:
|
|
76
|
+
"""Execute the full process chain.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
extra_context: Additional key-value context to inject into prompts.
|
|
80
|
+
approval_callback: Async function for manual approval steps.
|
|
81
|
+
Receives (step_name, enriched_prompt) → (approved, approved_by).
|
|
82
|
+
dry_run: If True, analyze prompts but don't call LLM.
|
|
83
|
+
**env_overrides: Override environment-level context (e.g., env="production").
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
ProcessResult with status of all steps.
|
|
87
|
+
"""
|
|
88
|
+
start_time = time.time()
|
|
89
|
+
ctx = self.context_engine.gather()
|
|
90
|
+
if extra_context:
|
|
91
|
+
ctx.update(extra_context)
|
|
92
|
+
ctx.update(env_overrides)
|
|
93
|
+
|
|
94
|
+
result = ProcessResult(
|
|
95
|
+
process_name=self.process_config.process,
|
|
96
|
+
started_at=datetime.utcnow(),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for step in self.process_config.steps:
|
|
100
|
+
step_result = await self._execute_step(
|
|
101
|
+
step=step,
|
|
102
|
+
ctx=ctx,
|
|
103
|
+
approval_callback=approval_callback,
|
|
104
|
+
dry_run=dry_run,
|
|
105
|
+
)
|
|
106
|
+
result.steps.append(step_result)
|
|
107
|
+
self._step_results[step.name] = step_result
|
|
108
|
+
|
|
109
|
+
if step_result.status == StepStatus.FAILED:
|
|
110
|
+
logger.error(f"Step '{step.name}' failed — halting chain.")
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if step_result.status == StepStatus.AWAITING_APPROVAL:
|
|
114
|
+
logger.info(f"Step '{step.name}' awaiting approval — chain paused.")
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
# Final status
|
|
118
|
+
all_completed = all(s.status == StepStatus.COMPLETED for s in result.steps)
|
|
119
|
+
result.completed = all_completed
|
|
120
|
+
result.total_duration_seconds = time.time() - start_time
|
|
121
|
+
result.finished_at = datetime.utcnow()
|
|
122
|
+
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
async def _execute_step(
|
|
126
|
+
self,
|
|
127
|
+
step: ProcessStep,
|
|
128
|
+
ctx: dict[str, str],
|
|
129
|
+
approval_callback: ApprovalCallback | None,
|
|
130
|
+
dry_run: bool,
|
|
131
|
+
) -> StepResult:
|
|
132
|
+
"""Execute a single step in the chain."""
|
|
133
|
+
step_start = time.time()
|
|
134
|
+
step_result = StepResult(step_name=step.name, status=StepStatus.RUNNING)
|
|
135
|
+
|
|
136
|
+
# Check dependencies
|
|
137
|
+
for dep in step.depends_on:
|
|
138
|
+
dep_result = self._step_results.get(dep)
|
|
139
|
+
if not dep_result or dep_result.status != StepStatus.COMPLETED:
|
|
140
|
+
step_result.status = StepStatus.FAILED
|
|
141
|
+
step_result.error = f"Dependency '{dep}' not completed"
|
|
142
|
+
return step_result
|
|
143
|
+
|
|
144
|
+
# Enrich prompt with context
|
|
145
|
+
enriched_prompt = self.context_engine.enrich_prompt(step.prompt, ctx)
|
|
146
|
+
|
|
147
|
+
logger.info(f"Step '{step.name}': enriched prompt = {enriched_prompt[:100]}...")
|
|
148
|
+
|
|
149
|
+
# Approval gate
|
|
150
|
+
if step.approval == ApprovalMode.MANUAL:
|
|
151
|
+
if approval_callback:
|
|
152
|
+
try:
|
|
153
|
+
approved, approved_by = await approval_callback(step.name, enriched_prompt)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
step_result.status = StepStatus.FAILED
|
|
156
|
+
step_result.error = f"Approval callback error: {e}"
|
|
157
|
+
return step_result
|
|
158
|
+
|
|
159
|
+
if not approved:
|
|
160
|
+
step_result.status = StepStatus.AWAITING_APPROVAL
|
|
161
|
+
return step_result
|
|
162
|
+
|
|
163
|
+
step_result.approved_by = approved_by
|
|
164
|
+
step_result.approved_at = datetime.utcnow()
|
|
165
|
+
step_result.status = StepStatus.APPROVED
|
|
166
|
+
else:
|
|
167
|
+
# No callback provided — pause for manual approval
|
|
168
|
+
step_result.status = StepStatus.AWAITING_APPROVAL
|
|
169
|
+
return step_result
|
|
170
|
+
|
|
171
|
+
# Dry run — just analyze, don't call LLM
|
|
172
|
+
if dry_run:
|
|
173
|
+
analysis = self.guard.analyze_only(enriched_prompt)
|
|
174
|
+
step_result.status = StepStatus.COMPLETED
|
|
175
|
+
step_result.duration_seconds = time.time() - step_start
|
|
176
|
+
logger.info(f"Step '{step.name}' (dry-run): {analysis}")
|
|
177
|
+
return step_result
|
|
178
|
+
|
|
179
|
+
# Execute through PromptGuard
|
|
180
|
+
try:
|
|
181
|
+
response = await self.guard(enriched_prompt, extra_context=ctx)
|
|
182
|
+
step_result.response = response
|
|
183
|
+
step_result.status = StepStatus.COMPLETED
|
|
184
|
+
|
|
185
|
+
self._audit_step(step.name, enriched_prompt, response.content)
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
step_result.status = StepStatus.FAILED
|
|
189
|
+
step_result.error = str(e)
|
|
190
|
+
logger.error(f"Step '{step.name}' failed: {e}")
|
|
191
|
+
|
|
192
|
+
if step.rollback:
|
|
193
|
+
logger.warning(f"Step '{step.name}' has rollback=true — rollback should be triggered")
|
|
194
|
+
step_result.status = StepStatus.ROLLED_BACK
|
|
195
|
+
|
|
196
|
+
step_result.duration_seconds = time.time() - step_start
|
|
197
|
+
return step_result
|
|
198
|
+
|
|
199
|
+
def get_audit_log(self) -> list[dict[str, Any]]:
|
|
200
|
+
return [e.model_dump() for e in self.audit_log]
|
|
201
|
+
|
|
202
|
+
def _audit_step(self, step_name: str, prompt: str, response: str) -> None:
|
|
203
|
+
entry = AuditEntry(
|
|
204
|
+
action="process_step",
|
|
205
|
+
query=prompt,
|
|
206
|
+
response_summary=response[:200] if response else "",
|
|
207
|
+
step_name=step_name,
|
|
208
|
+
process_name=self.process_config.process,
|
|
209
|
+
)
|
|
210
|
+
self.audit_log.append(entry)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _load_process_config(path: Path) -> ProcessConfig:
|
|
214
|
+
with open(path) as f:
|
|
215
|
+
raw = yaml.safe_load(f) or {}
|
|
216
|
+
|
|
217
|
+
steps = []
|
|
218
|
+
for s in raw.get("steps", []):
|
|
219
|
+
steps.append(ProcessStep(**s))
|
|
220
|
+
|
|
221
|
+
return ProcessConfig(
|
|
222
|
+
process=raw.get("process", "unnamed"),
|
|
223
|
+
description=raw.get("description", ""),
|
|
224
|
+
context_sources=raw.get("context_sources", []),
|
|
225
|
+
steps=steps,
|
|
226
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""PromptGuard CLI — run prompts, execute process chains, and manage configs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="promptguard",
|
|
15
|
+
help="PromptGuard — LLM prompt middleware for bias detection, standardization, and DevOps process chains.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def run(
|
|
22
|
+
query: str = typer.Argument(..., help="The prompt/query to process"),
|
|
23
|
+
config: Path = typer.Option("rules.yaml", "--config", "-c", help="Path to YAML config"),
|
|
24
|
+
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="LLM model to use"),
|
|
25
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-d", help="Analyze only, don't call LLM"),
|
|
26
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
27
|
+
):
|
|
28
|
+
"""Run a single query through PromptGuard."""
|
|
29
|
+
from promptguard.core import PromptGuard
|
|
30
|
+
|
|
31
|
+
guard = PromptGuard(config_path=config)
|
|
32
|
+
|
|
33
|
+
if dry_run:
|
|
34
|
+
result = guard.analyze_only(query)
|
|
35
|
+
if json_output:
|
|
36
|
+
typer.echo(json.dumps(result, indent=2, default=str))
|
|
37
|
+
else:
|
|
38
|
+
typer.echo(f"🔍 Analysis for: {query}")
|
|
39
|
+
typer.echo(f" Needs clarification: {result['needs_clarify']}")
|
|
40
|
+
typer.echo(f" Patterns detected: {result['patterns']}")
|
|
41
|
+
typer.echo(f" Ambiguity flags: {result['ambiguity_flags']}")
|
|
42
|
+
typer.echo(f" Enriched query: {result['enriched']}")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
result = asyncio.run(_run_guard(guard, query, model))
|
|
46
|
+
|
|
47
|
+
if json_output:
|
|
48
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
49
|
+
else:
|
|
50
|
+
status = "✅ clarified" if result.clarified else "📝 direct"
|
|
51
|
+
typer.echo(f"\n{'='*60}")
|
|
52
|
+
typer.echo(f"🛡️ PromptGuard [{status}] via {result.model_used}")
|
|
53
|
+
typer.echo(f"{'='*60}")
|
|
54
|
+
typer.echo(f"\n{result.content}")
|
|
55
|
+
if result.analysis and result.analysis.detected_patterns:
|
|
56
|
+
typer.echo(f"\n⚠️ Detected: {', '.join(result.analysis.detected_patterns)}")
|
|
57
|
+
typer.echo(f"\n{'='*60}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def process(
|
|
62
|
+
config: Path = typer.Argument(..., help="Path to process chain YAML"),
|
|
63
|
+
guard_config: Path = typer.Option("rules.yaml", "--guard-config", "-g", help="Path to guard YAML config"),
|
|
64
|
+
dry_run: bool = typer.Option(False, "--dry-run", "-d", help="Analyze steps without calling LLM"),
|
|
65
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
66
|
+
env: Optional[str] = typer.Option(None, "--env", "-e", help="Environment override (e.g., production)"),
|
|
67
|
+
):
|
|
68
|
+
"""Execute a DevOps process chain."""
|
|
69
|
+
from promptguard.chains.process_chain import ProcessChain
|
|
70
|
+
|
|
71
|
+
chain = ProcessChain(config_path=config, guard_config_path=guard_config)
|
|
72
|
+
|
|
73
|
+
extra = {}
|
|
74
|
+
if env:
|
|
75
|
+
extra["env"] = env
|
|
76
|
+
|
|
77
|
+
result = asyncio.run(chain.execute(extra_context=extra, dry_run=dry_run))
|
|
78
|
+
|
|
79
|
+
if json_output:
|
|
80
|
+
typer.echo(result.model_dump_json(indent=2))
|
|
81
|
+
else:
|
|
82
|
+
typer.echo(f"\n{'='*60}")
|
|
83
|
+
typer.echo(f"🔗 Process: {result.process_name}")
|
|
84
|
+
typer.echo(f" Status: {'✅ Completed' if result.completed else '⏸️ Incomplete'}")
|
|
85
|
+
typer.echo(f" Duration: {result.total_duration_seconds:.2f}s")
|
|
86
|
+
typer.echo(f"{'='*60}")
|
|
87
|
+
for step in result.steps:
|
|
88
|
+
icon = {
|
|
89
|
+
"completed": "✅",
|
|
90
|
+
"failed": "❌",
|
|
91
|
+
"awaiting_approval": "⏳",
|
|
92
|
+
"rolled_back": "↩️",
|
|
93
|
+
}.get(step.status.value, "🔄")
|
|
94
|
+
typer.echo(f" {icon} {step.step_name}: {step.status.value} ({step.duration_seconds:.2f}s)")
|
|
95
|
+
if step.error:
|
|
96
|
+
typer.echo(f" Error: {step.error}")
|
|
97
|
+
typer.echo(f"{'='*60}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command()
|
|
101
|
+
def analyze(
|
|
102
|
+
query: str = typer.Argument(..., help="Query to analyze for bias/ambiguity"),
|
|
103
|
+
config: Path = typer.Option("rules.yaml", "--config", "-c", help="Path to YAML config"),
|
|
104
|
+
):
|
|
105
|
+
"""Analyze a query without calling any LLM (bias detection + ambiguity check)."""
|
|
106
|
+
from promptguard.core import PromptGuard
|
|
107
|
+
|
|
108
|
+
guard = PromptGuard(config_path=config)
|
|
109
|
+
result = guard.analyze_only(query)
|
|
110
|
+
|
|
111
|
+
typer.echo(f"\n🔍 Analysis: {query}")
|
|
112
|
+
typer.echo(f" Needs clarification: {'⚠️ YES' if result['needs_clarify'] else '✅ NO'}")
|
|
113
|
+
if result["patterns"]:
|
|
114
|
+
typer.echo(f" Patterns: {', '.join(result['patterns'])}")
|
|
115
|
+
if result["ambiguity_flags"]:
|
|
116
|
+
typer.echo(f" Flags: {', '.join(result['ambiguity_flags'])}")
|
|
117
|
+
if result["readability"] is not None:
|
|
118
|
+
typer.echo(f" Readability: {result['readability']:.1f}")
|
|
119
|
+
typer.echo(f" Enriched: {result['enriched']}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command()
|
|
123
|
+
def init(
|
|
124
|
+
output: Path = typer.Option("rules.yaml", "--output", "-o", help="Output path for config"),
|
|
125
|
+
devops: bool = typer.Option(False, "--devops", help="Include DevOps-specific patterns"),
|
|
126
|
+
):
|
|
127
|
+
"""Generate a starter rules.yaml config file."""
|
|
128
|
+
import yaml
|
|
129
|
+
|
|
130
|
+
config = {
|
|
131
|
+
"bias_patterns": [
|
|
132
|
+
{"regex": "(zawsze|always)\\s+\\w+", "action": "clarify", "severity": "medium",
|
|
133
|
+
"description": "Absolute quantifier"},
|
|
134
|
+
{"regex": "(tylko|only|just)\\s+\\w+", "action": "clarify", "severity": "low",
|
|
135
|
+
"description": "Exclusive quantifier"},
|
|
136
|
+
],
|
|
137
|
+
"clarify_template": "[KONTEKST]: Podaj szczegóły lub alternatywy dla: {query}",
|
|
138
|
+
"max_retries": 3,
|
|
139
|
+
"policy": "strict",
|
|
140
|
+
"models": {
|
|
141
|
+
"fallback": ["gpt-4o-mini", "llama3"],
|
|
142
|
+
"timeout": 30,
|
|
143
|
+
"max_tokens": 2048,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if devops:
|
|
148
|
+
config["bias_patterns"].extend([
|
|
149
|
+
{"regex": "(deploy|zdeployuj)\\s+(na|to)\\s+(prod|production)", "action": "clarify",
|
|
150
|
+
"severity": "critical", "description": "Production deployment without context"},
|
|
151
|
+
{"regex": "(delete|drop|remove|usuń)\\s+(database|db|baz)", "action": "clarify",
|
|
152
|
+
"severity": "critical", "description": "Destructive DB operation"},
|
|
153
|
+
{"regex": "(restart|reboot|kill)\\s+(server|service|pod)", "action": "clarify",
|
|
154
|
+
"severity": "high", "description": "Service disruption"},
|
|
155
|
+
{"regex": "(scale|skaluj)\\s+(down|up|to\\s+\\d+)", "action": "clarify",
|
|
156
|
+
"severity": "high", "description": "Scaling operation"},
|
|
157
|
+
])
|
|
158
|
+
config["context_sources"] = [
|
|
159
|
+
{"env": ["CLUSTER", "NAMESPACE", "GIT_SHA", "ENV"]},
|
|
160
|
+
{"git": ["branch", "short_sha", "last_commit_msg"]},
|
|
161
|
+
{"system": ["hostname", "os"]},
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
with open(output, "w") as f:
|
|
165
|
+
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
166
|
+
|
|
167
|
+
typer.echo(f"✅ Config written to {output}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _run_guard(guard, query: str, model: str):
|
|
171
|
+
return await guard(query, model=model)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
app()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Core PromptGuard — the main entry point for prompt analysis, enrichment, and LLM calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from promptguard.analyzers.bias_detector import BiasDetector
|
|
13
|
+
from promptguard.analyzers.context_engine import ContextEngine
|
|
14
|
+
from promptguard.models import (
|
|
15
|
+
AuditEntry,
|
|
16
|
+
BiasPattern,
|
|
17
|
+
GuardConfig,
|
|
18
|
+
GuardResponse,
|
|
19
|
+
ModelConfig,
|
|
20
|
+
Policy,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("promptguard")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromptGuard:
|
|
27
|
+
"""Main PromptGuard middleware — analyze, enrich, and proxy LLM calls.
|
|
28
|
+
|
|
29
|
+
Usage:
|
|
30
|
+
guard = PromptGuard("rules.yaml")
|
|
31
|
+
result = await guard("Zdeployuj na produkcję", model="gpt-4o-mini")
|
|
32
|
+
|
|
33
|
+
Or with inline config:
|
|
34
|
+
guard = PromptGuard(config=GuardConfig(policy=Policy.DEVOPS))
|
|
35
|
+
result = await guard("Deploy the app")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
config_path: str | Path | None = None,
|
|
41
|
+
config: GuardConfig | None = None,
|
|
42
|
+
):
|
|
43
|
+
if config:
|
|
44
|
+
self.config = config
|
|
45
|
+
elif config_path:
|
|
46
|
+
self.config = self._load_config(Path(config_path))
|
|
47
|
+
else:
|
|
48
|
+
self.config = GuardConfig()
|
|
49
|
+
|
|
50
|
+
self.detector = BiasDetector(self.config.bias_patterns or None)
|
|
51
|
+
self.context_engine = ContextEngine(self.config.context_sources)
|
|
52
|
+
self.audit_log: list[AuditEntry] = []
|
|
53
|
+
|
|
54
|
+
async def __call__(
|
|
55
|
+
self,
|
|
56
|
+
query: str,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
extra_context: dict[str, str] | None = None,
|
|
59
|
+
**kwargs: Any,
|
|
60
|
+
) -> GuardResponse:
|
|
61
|
+
"""Analyze query, enrich if needed, call LLM, validate response."""
|
|
62
|
+
import litellm
|
|
63
|
+
|
|
64
|
+
target_model = model or self.config.models.fallback[0]
|
|
65
|
+
|
|
66
|
+
# Step 1: Analyze
|
|
67
|
+
analysis = self.detector.analyze(query)
|
|
68
|
+
analysis.original_query = query
|
|
69
|
+
|
|
70
|
+
# Step 2: Enrich if needed
|
|
71
|
+
if analysis.needs_clarify:
|
|
72
|
+
enriched = self.config.clarify_template.format(query=query)
|
|
73
|
+
enriched = self.context_engine.enrich_prompt(enriched, extra_context)
|
|
74
|
+
analysis.enriched_query = enriched
|
|
75
|
+
else:
|
|
76
|
+
enriched = self.context_engine.enrich_prompt(query, extra_context)
|
|
77
|
+
analysis.enriched_query = enriched
|
|
78
|
+
|
|
79
|
+
# Step 3: Call LLM with retry/fallback
|
|
80
|
+
response_content = ""
|
|
81
|
+
model_used = target_model
|
|
82
|
+
retries = 0
|
|
83
|
+
|
|
84
|
+
fallback_models = [target_model] + [
|
|
85
|
+
m for m in self.config.models.fallback if m != target_model
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
for attempt_model in fallback_models:
|
|
89
|
+
for attempt in range(self.config.max_retries):
|
|
90
|
+
try:
|
|
91
|
+
resp = await litellm.acompletion(
|
|
92
|
+
model=attempt_model,
|
|
93
|
+
messages=[{"role": "user", "content": enriched}],
|
|
94
|
+
max_tokens=self.config.models.max_tokens,
|
|
95
|
+
timeout=self.config.models.timeout,
|
|
96
|
+
**kwargs,
|
|
97
|
+
)
|
|
98
|
+
response_content = resp.choices[0].message.content
|
|
99
|
+
model_used = attempt_model
|
|
100
|
+
break
|
|
101
|
+
except Exception as e:
|
|
102
|
+
retries += 1
|
|
103
|
+
logger.warning(f"Attempt {attempt + 1} with {attempt_model} failed: {e}")
|
|
104
|
+
if response_content:
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
# Step 4: Build response
|
|
108
|
+
result = GuardResponse(
|
|
109
|
+
content=response_content or "No response from any model.",
|
|
110
|
+
clarified=analysis.needs_clarify,
|
|
111
|
+
needs_more_context=analysis.needs_clarify and not response_content,
|
|
112
|
+
model_used=model_used,
|
|
113
|
+
analysis=analysis,
|
|
114
|
+
retries=retries,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Audit
|
|
118
|
+
self._audit("query", query, result, model_used)
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
def analyze_only(self, query: str) -> dict[str, Any]:
|
|
123
|
+
"""Run analysis without calling LLM — useful for dry-run / testing."""
|
|
124
|
+
analysis = self.detector.analyze(query)
|
|
125
|
+
return {
|
|
126
|
+
"needs_clarify": analysis.needs_clarify,
|
|
127
|
+
"patterns": analysis.detected_patterns,
|
|
128
|
+
"ambiguity_flags": analysis.ambiguity_flags,
|
|
129
|
+
"readability": analysis.readability_score,
|
|
130
|
+
"enriched": self.config.clarify_template.format(query=query)
|
|
131
|
+
if analysis.needs_clarify
|
|
132
|
+
else query,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def get_audit_log(self) -> list[dict[str, Any]]:
|
|
136
|
+
"""Return audit log as list of dicts."""
|
|
137
|
+
return [entry.model_dump() for entry in self.audit_log]
|
|
138
|
+
|
|
139
|
+
def _audit(self, action: str, query: str, response: GuardResponse, model: str) -> None:
|
|
140
|
+
entry = AuditEntry(
|
|
141
|
+
action=action,
|
|
142
|
+
query=query,
|
|
143
|
+
response_summary=response.content[:200] if response.content else "",
|
|
144
|
+
model=model,
|
|
145
|
+
policy=self.config.policy,
|
|
146
|
+
)
|
|
147
|
+
self.audit_log.append(entry)
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _load_config(path: Path) -> GuardConfig:
|
|
151
|
+
"""Load config from YAML file."""
|
|
152
|
+
with open(path) as f:
|
|
153
|
+
raw = yaml.safe_load(f) or {}
|
|
154
|
+
|
|
155
|
+
# Normalize bias_patterns
|
|
156
|
+
patterns = []
|
|
157
|
+
for p in raw.get("bias_patterns", []):
|
|
158
|
+
if isinstance(p, dict):
|
|
159
|
+
patterns.append(BiasPattern(**p))
|
|
160
|
+
|
|
161
|
+
models_raw = raw.get("models", {})
|
|
162
|
+
models = ModelConfig(**models_raw) if isinstance(models_raw, dict) else ModelConfig()
|
|
163
|
+
|
|
164
|
+
return GuardConfig(
|
|
165
|
+
bias_patterns=patterns,
|
|
166
|
+
clarify_template=raw.get("clarify_template", GuardConfig.model_fields["clarify_template"].default),
|
|
167
|
+
max_retries=raw.get("max_retries", 3),
|
|
168
|
+
policy=Policy(raw.get("policy", "strict")),
|
|
169
|
+
models=models,
|
|
170
|
+
context_sources=raw.get("context_sources", []),
|
|
171
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Data models for PromptGuard — all inputs/outputs are Pydantic v2 validated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --- Enums ---
|
|
13
|
+
|
|
14
|
+
class Policy(str, enum.Enum):
|
|
15
|
+
STRICT = "strict"
|
|
16
|
+
LENIENT = "lenient"
|
|
17
|
+
DEVOPS = "devops"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ApprovalMode(str, enum.Enum):
|
|
21
|
+
AUTO = "auto"
|
|
22
|
+
MANUAL = "manual"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StepStatus(str, enum.Enum):
|
|
26
|
+
PENDING = "pending"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
AWAITING_APPROVAL = "awaiting_approval"
|
|
29
|
+
APPROVED = "approved"
|
|
30
|
+
COMPLETED = "completed"
|
|
31
|
+
FAILED = "failed"
|
|
32
|
+
ROLLED_BACK = "rolled_back"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- Config models ---
|
|
36
|
+
|
|
37
|
+
class BiasPattern(BaseModel):
|
|
38
|
+
regex: str
|
|
39
|
+
action: str = "clarify"
|
|
40
|
+
severity: str = "medium"
|
|
41
|
+
description: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ModelConfig(BaseModel):
|
|
45
|
+
fallback: list[str] = Field(default_factory=lambda: ["gpt-4o-mini"])
|
|
46
|
+
timeout: int = 30
|
|
47
|
+
max_tokens: int = 2048
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GuardConfig(BaseModel):
|
|
51
|
+
"""Top-level YAML config model."""
|
|
52
|
+
bias_patterns: list[BiasPattern] = Field(default_factory=list)
|
|
53
|
+
clarify_template: str = "[KONTEKST]: Podaj szczegóły lub alternatywy dla: {query}"
|
|
54
|
+
max_retries: int = 3
|
|
55
|
+
policy: Policy = Policy.STRICT
|
|
56
|
+
models: ModelConfig = Field(default_factory=ModelConfig)
|
|
57
|
+
context_sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Process chain models ---
|
|
61
|
+
|
|
62
|
+
class ProcessStep(BaseModel):
|
|
63
|
+
name: str
|
|
64
|
+
prompt: str
|
|
65
|
+
policy: Policy = Policy.STRICT
|
|
66
|
+
approval: ApprovalMode = ApprovalMode.AUTO
|
|
67
|
+
rollback: bool = False
|
|
68
|
+
timeout: int = 300
|
|
69
|
+
depends_on: list[str] = Field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ProcessConfig(BaseModel):
|
|
73
|
+
process: str
|
|
74
|
+
description: str = ""
|
|
75
|
+
context_sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
76
|
+
steps: list[ProcessStep]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- Runtime models ---
|
|
80
|
+
|
|
81
|
+
class AnalysisResult(BaseModel):
|
|
82
|
+
"""Result of query analysis — what was detected and what was done."""
|
|
83
|
+
needs_clarify: bool = False
|
|
84
|
+
detected_patterns: list[str] = Field(default_factory=list)
|
|
85
|
+
enriched_query: str = ""
|
|
86
|
+
original_query: str = ""
|
|
87
|
+
readability_score: float | None = None
|
|
88
|
+
ambiguity_flags: list[str] = Field(default_factory=list)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GuardResponse(BaseModel):
|
|
92
|
+
"""Response from PromptGuard — the final output after analysis and LLM call."""
|
|
93
|
+
content: str
|
|
94
|
+
clarified: bool = False
|
|
95
|
+
needs_more_context: bool = False
|
|
96
|
+
model_used: str = ""
|
|
97
|
+
analysis: AnalysisResult | None = None
|
|
98
|
+
retries: int = 0
|
|
99
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StepResult(BaseModel):
|
|
103
|
+
"""Result of a single process chain step."""
|
|
104
|
+
step_name: str
|
|
105
|
+
status: StepStatus = StepStatus.PENDING
|
|
106
|
+
response: GuardResponse | None = None
|
|
107
|
+
approved_by: str | None = None
|
|
108
|
+
approved_at: datetime | None = None
|
|
109
|
+
error: str | None = None
|
|
110
|
+
duration_seconds: float = 0.0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ProcessResult(BaseModel):
|
|
114
|
+
"""Result of a full process chain execution."""
|
|
115
|
+
process_name: str
|
|
116
|
+
steps: list[StepResult] = Field(default_factory=list)
|
|
117
|
+
completed: bool = False
|
|
118
|
+
total_duration_seconds: float = 0.0
|
|
119
|
+
started_at: datetime = Field(default_factory=datetime.utcnow)
|
|
120
|
+
finished_at: datetime | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- Audit models ---
|
|
124
|
+
|
|
125
|
+
class AuditEntry(BaseModel):
|
|
126
|
+
"""Single audit log entry for traceability."""
|
|
127
|
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
128
|
+
action: str
|
|
129
|
+
query: str = ""
|
|
130
|
+
response_summary: str = ""
|
|
131
|
+
model: str = ""
|
|
132
|
+
policy: Policy = Policy.STRICT
|
|
133
|
+
step_name: str | None = None
|
|
134
|
+
process_name: str | None = None
|
|
135
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "prellm"
|
|
3
|
+
version = "0.1.12"
|
|
4
|
+
description = "PromptGuard - Lightweight LLM prompt middleware for bias detection, standardization, and DevOps process chains via YAML config, supporting context injection and multi-provider orchestration."
|
|
5
|
+
authors = [
|
|
6
|
+
"Softreck <tom@sapletta.com>",
|
|
7
|
+
"Tom Sapletta <tom@sapletta.com>",
|
|
8
|
+
]
|
|
9
|
+
license = "Apache-2.0"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
repository = "https://github.com/softreck/gllm"
|
|
12
|
+
keywords = ["llm", "prompt-engineering", "bias-detection", "devops", "litellm"]
|
|
13
|
+
packages = [{include = "promptguard"}]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.dependencies]
|
|
21
|
+
python = "^3.10"
|
|
22
|
+
litellm = "^1.40"
|
|
23
|
+
pydantic = "^2.0"
|
|
24
|
+
pyyaml = "^6.0"
|
|
25
|
+
nltk = "^3.8"
|
|
26
|
+
typer = {version = "^0.12", extras = ["all"]}
|
|
27
|
+
textstat = "^0.7"
|
|
28
|
+
|
|
29
|
+
[tool.poetry.group.dev.dependencies]
|
|
30
|
+
pytest = "^8.0"
|
|
31
|
+
pytest-asyncio = "^0.23"
|
|
32
|
+
pytest-mock = "^3.12"
|
|
33
|
+
ruff = "^0.4"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.scripts]
|
|
36
|
+
promptguard = "promptguard.cli:app"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["poetry-core"]
|
|
40
|
+
build-backend = "poetry.core.masonry.api"
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
target-version = "py310"
|
|
44
|
+
line-length = 120
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
testpaths = ["tests"]
|