verdant 0.1.0__py3-none-any.whl
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.
- verdant/__init__.py +35 -0
- verdant/client.py +114 -0
- verdant/config.py +59 -0
- verdant/models.py +152 -0
- verdant/pipeline.py +274 -0
- verdant/services/__init__.py +1 -0
- verdant/services/cache_service.py +134 -0
- verdant/services/claude_service.py +158 -0
- verdant/services/db_service.py +427 -0
- verdant/services/gemini_service.py +142 -0
- verdant/services/prompts/explain.txt +14 -0
- verdant/services/prompts/generate.txt +9 -0
- verdant/services/prompts/intent.txt +16 -0
- verdant/stages/__init__.py +13 -0
- verdant/stages/baseline.py +177 -0
- verdant/stages/bias.py +82 -0
- verdant/stages/explain.py +87 -0
- verdant/stages/intent.py +120 -0
- verdant/stages/trust.py +114 -0
- verdant-0.1.0.dist-info/METADATA +73 -0
- verdant-0.1.0.dist-info/RECORD +24 -0
- verdant-0.1.0.dist-info/WHEEL +5 -0
- verdant-0.1.0.dist-info/licenses/LICENSE +21 -0
- verdant-0.1.0.dist-info/top_level.txt +1 -0
verdant/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from .config import Settings, get_settings
|
|
2
|
+
from .client import VerdantAPIError, VerdantClient
|
|
3
|
+
from .models import (
|
|
4
|
+
AuditPayload,
|
|
5
|
+
BaselineStageOutput,
|
|
6
|
+
BiasSeverity,
|
|
7
|
+
BiasStageOutput,
|
|
8
|
+
ContextType,
|
|
9
|
+
ExplainStageOutput,
|
|
10
|
+
IntentStageOutput,
|
|
11
|
+
PipelineRunRequest,
|
|
12
|
+
PipelineStageOutputs,
|
|
13
|
+
RiskLevel,
|
|
14
|
+
TrustStageOutput,
|
|
15
|
+
WrapResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AuditPayload",
|
|
20
|
+
"BaselineStageOutput",
|
|
21
|
+
"BiasSeverity",
|
|
22
|
+
"BiasStageOutput",
|
|
23
|
+
"ContextType",
|
|
24
|
+
"ExplainStageOutput",
|
|
25
|
+
"IntentStageOutput",
|
|
26
|
+
"PipelineRunRequest",
|
|
27
|
+
"PipelineStageOutputs",
|
|
28
|
+
"RiskLevel",
|
|
29
|
+
"Settings",
|
|
30
|
+
"TrustStageOutput",
|
|
31
|
+
"VerdantAPIError",
|
|
32
|
+
"VerdantClient",
|
|
33
|
+
"WrapResult",
|
|
34
|
+
"get_settings",
|
|
35
|
+
]
|
verdant/client.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .config import Settings, get_settings
|
|
8
|
+
from .models import ContextType, WrapResult
|
|
9
|
+
from .pipeline import VerdantPipeline
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VerdantClient:
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_key: str | None = None,
|
|
16
|
+
*,
|
|
17
|
+
base_url: str | None = None,
|
|
18
|
+
settings: Settings | None = None,
|
|
19
|
+
pipeline: VerdantPipeline | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.settings = settings or get_settings()
|
|
22
|
+
updates: dict[str, Any] = {}
|
|
23
|
+
if api_key:
|
|
24
|
+
updates["verdant_api_key"] = api_key
|
|
25
|
+
if base_url:
|
|
26
|
+
updates["verdant_api_url"] = base_url
|
|
27
|
+
if updates:
|
|
28
|
+
self.settings = self.settings.model_copy(update=updates)
|
|
29
|
+
self.pipeline = pipeline or VerdantPipeline(self.settings)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def _is_hosted(self) -> bool:
|
|
33
|
+
"""Use the hosted API when both a base URL and a VERDANT key are configured."""
|
|
34
|
+
return bool(self.settings.verdant_api_url and self.settings.verdant_api_key)
|
|
35
|
+
|
|
36
|
+
async def wrap(
|
|
37
|
+
self,
|
|
38
|
+
fn: Callable[..., Any],
|
|
39
|
+
*,
|
|
40
|
+
context_type: str | ContextType | None = None,
|
|
41
|
+
input_text: str | None = None,
|
|
42
|
+
metadata: dict[str, Any] | None = None,
|
|
43
|
+
**fn_kwargs: Any,
|
|
44
|
+
) -> WrapResult:
|
|
45
|
+
"""Wrap a local model call and run the reasoning pipeline in-process."""
|
|
46
|
+
return await self.pipeline.run(
|
|
47
|
+
fn=fn,
|
|
48
|
+
context_type=context_type,
|
|
49
|
+
input_text=input_text,
|
|
50
|
+
fn_kwargs=fn_kwargs,
|
|
51
|
+
metadata=metadata,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
async def run(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
context_type: str | ContextType,
|
|
58
|
+
input_text: str,
|
|
59
|
+
metadata: dict[str, Any] | None = None,
|
|
60
|
+
) -> WrapResult:
|
|
61
|
+
"""Run the reasoning pipeline for the given input.
|
|
62
|
+
|
|
63
|
+
In hosted mode (a ``verdant_api_url`` and ``verdant_api_key`` are set) this calls
|
|
64
|
+
the VERDANT API over HTTP, so provider (Claude/Gemini) keys stay server-side. Otherwise
|
|
65
|
+
it runs the pipeline in-process.
|
|
66
|
+
"""
|
|
67
|
+
if self._is_hosted:
|
|
68
|
+
return await self._run_hosted(context_type=context_type, input_text=input_text, metadata=metadata)
|
|
69
|
+
return await self.pipeline.run(
|
|
70
|
+
context_type=context_type,
|
|
71
|
+
input_text=input_text,
|
|
72
|
+
metadata=metadata,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def _run_hosted(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
context_type: str | ContextType,
|
|
79
|
+
input_text: str,
|
|
80
|
+
metadata: dict[str, Any] | None,
|
|
81
|
+
) -> WrapResult:
|
|
82
|
+
url = self.settings.verdant_api_url.rstrip("/") + "/pipeline/run"
|
|
83
|
+
context_value = context_type.value if isinstance(context_type, ContextType) else str(context_type)
|
|
84
|
+
payload = {
|
|
85
|
+
"context_type": context_value,
|
|
86
|
+
"input_text": input_text,
|
|
87
|
+
"metadata": metadata or {},
|
|
88
|
+
}
|
|
89
|
+
headers = {"Authorization": f"Bearer {self.settings.verdant_api_key}"}
|
|
90
|
+
|
|
91
|
+
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
|
|
92
|
+
response = await client.post(url, json=payload, headers=headers)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
body = response.json()
|
|
96
|
+
except ValueError:
|
|
97
|
+
body = None
|
|
98
|
+
|
|
99
|
+
if response.status_code >= 400:
|
|
100
|
+
message = None
|
|
101
|
+
if isinstance(body, dict) and body.get("error"):
|
|
102
|
+
message = body["error"].get("message")
|
|
103
|
+
raise VerdantAPIError(message or f"VERDANT API returned {response.status_code}", status_code=response.status_code)
|
|
104
|
+
|
|
105
|
+
if not isinstance(body, dict) or body.get("data") is None:
|
|
106
|
+
raise VerdantAPIError("VERDANT API returned an unexpected response.", status_code=response.status_code)
|
|
107
|
+
|
|
108
|
+
return WrapResult.model_validate(body["data"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class VerdantAPIError(RuntimeError):
|
|
112
|
+
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
self.status_code = status_code
|
verdant/config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _split_csv(value: str | None, default: list[str]) -> list[str]:
|
|
10
|
+
if not value:
|
|
11
|
+
return default
|
|
12
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Settings(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="ignore")
|
|
17
|
+
|
|
18
|
+
verdant_api_key: str = ""
|
|
19
|
+
verdant_api_url: str = ""
|
|
20
|
+
anthropic_api_key: str = ""
|
|
21
|
+
gemini_api_key: str = ""
|
|
22
|
+
supabase_url: str = ""
|
|
23
|
+
supabase_service_role_key: str = ""
|
|
24
|
+
redis_url: str = "redis://localhost:6379/0"
|
|
25
|
+
webhook_secret: str = ""
|
|
26
|
+
environment: str = "development"
|
|
27
|
+
log_level: str = "info"
|
|
28
|
+
trust_score_alert_threshold: int = Field(default=40, ge=0, le=100)
|
|
29
|
+
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
|
30
|
+
claude_model: str = "claude-sonnet-4-6"
|
|
31
|
+
gemini_model: str = "gemini-2.5-flash"
|
|
32
|
+
request_timeout_seconds: float = 30.0
|
|
33
|
+
webhook_timeout_seconds: float = 10.0
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_env(cls) -> "Settings":
|
|
37
|
+
return cls(
|
|
38
|
+
verdant_api_key=os.getenv("VERDANT_API_KEY", ""),
|
|
39
|
+
verdant_api_url=os.getenv("VERDANT_API_URL", ""),
|
|
40
|
+
anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""),
|
|
41
|
+
gemini_api_key=os.getenv("GEMINI_API_KEY", ""),
|
|
42
|
+
supabase_url=os.getenv("SUPABASE_URL", ""),
|
|
43
|
+
supabase_service_role_key=os.getenv("SUPABASE_SERVICE_ROLE_KEY", ""),
|
|
44
|
+
redis_url=os.getenv("REDIS_URL", "redis://localhost:6379/0"),
|
|
45
|
+
webhook_secret=os.getenv("WEBHOOK_SECRET", ""),
|
|
46
|
+
environment=os.getenv("ENVIRONMENT", "development"),
|
|
47
|
+
log_level=os.getenv("LOG_LEVEL", "info"),
|
|
48
|
+
trust_score_alert_threshold=int(os.getenv("TRUST_SCORE_ALERT_THRESHOLD", "40")),
|
|
49
|
+
cors_origins=_split_csv(os.getenv("CORS_ORIGINS"), ["*"]),
|
|
50
|
+
claude_model=os.getenv("CLAUDE_MODEL", "claude-sonnet-4-6"),
|
|
51
|
+
gemini_model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
|
|
52
|
+
request_timeout_seconds=float(os.getenv("REQUEST_TIMEOUT_SECONDS", "30")),
|
|
53
|
+
webhook_timeout_seconds=float(os.getenv("WEBHOOK_TIMEOUT_SECONDS", "10")),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@lru_cache(maxsize=1)
|
|
58
|
+
def get_settings() -> Settings:
|
|
59
|
+
return Settings.from_env()
|
verdant/models.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VerdantBaseModel(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True, protected_namespaces=())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextType(str, Enum):
|
|
16
|
+
hiring = "hiring"
|
|
17
|
+
lending = "lending"
|
|
18
|
+
content = "content"
|
|
19
|
+
healthcare = "healthcare"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def normalize(cls, value: str | ContextType | None) -> ContextType:
|
|
23
|
+
if isinstance(value, cls):
|
|
24
|
+
return value
|
|
25
|
+
if value is None:
|
|
26
|
+
return cls.content
|
|
27
|
+
|
|
28
|
+
normalized = str(value).strip().lower().replace(" ", "_").replace("-", "_")
|
|
29
|
+
aliases = {
|
|
30
|
+
"content_moderation": cls.content,
|
|
31
|
+
"content_moderation_review": cls.content,
|
|
32
|
+
"moderation": cls.content,
|
|
33
|
+
}
|
|
34
|
+
if normalized in aliases:
|
|
35
|
+
return aliases[normalized]
|
|
36
|
+
|
|
37
|
+
for member in cls:
|
|
38
|
+
if member.value == normalized:
|
|
39
|
+
return member
|
|
40
|
+
raise ValueError(f"Unsupported context type: {value}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BiasSeverity(str, Enum):
|
|
44
|
+
low = "low"
|
|
45
|
+
medium = "medium"
|
|
46
|
+
high = "high"
|
|
47
|
+
critical = "critical"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RiskLevel(str, Enum):
|
|
51
|
+
low = "low"
|
|
52
|
+
medium = "medium"
|
|
53
|
+
high = "high"
|
|
54
|
+
critical = "critical"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class IntentStageOutput(VerdantBaseModel):
|
|
58
|
+
detected_intent: str
|
|
59
|
+
context_type: ContextType
|
|
60
|
+
user_intent_summary: str
|
|
61
|
+
entities: list[str] = Field(default_factory=list)
|
|
62
|
+
signals: list[str] = Field(default_factory=list)
|
|
63
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
64
|
+
needs_review: bool = False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class BaselineStageOutput(VerdantBaseModel):
|
|
68
|
+
context_type: ContextType
|
|
69
|
+
baseline_name: str
|
|
70
|
+
baseline_version: str
|
|
71
|
+
demographic_focus: list[str] = Field(default_factory=list)
|
|
72
|
+
baseline_summary: str
|
|
73
|
+
policy_notes: list[str] = Field(default_factory=list)
|
|
74
|
+
baseline_data: dict[str, Any] = Field(default_factory=dict)
|
|
75
|
+
source: str = "supabase"
|
|
76
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class BiasStageOutput(VerdantBaseModel):
|
|
80
|
+
flags: list[str] = Field(default_factory=list)
|
|
81
|
+
matched_patterns: list[str] = Field(default_factory=list)
|
|
82
|
+
severity: BiasSeverity = BiasSeverity.low
|
|
83
|
+
bias_score: int = Field(ge=0, le=100)
|
|
84
|
+
summary: str
|
|
85
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
86
|
+
|
|
87
|
+
@field_validator("bias_score", mode="before")
|
|
88
|
+
@classmethod
|
|
89
|
+
def _clamp_bias_score(cls, value: Any) -> int:
|
|
90
|
+
return max(0, min(100, int(value)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ExplainStageOutput(VerdantBaseModel):
|
|
94
|
+
plain_language_explanation: str
|
|
95
|
+
reasoning_summary: list[str] = Field(default_factory=list)
|
|
96
|
+
caveats: list[str] = Field(default_factory=list)
|
|
97
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TrustStageOutput(VerdantBaseModel):
|
|
101
|
+
trust_score: int = Field(ge=0, le=100)
|
|
102
|
+
risk_level: RiskLevel
|
|
103
|
+
score_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
104
|
+
reasons: list[str] = Field(default_factory=list)
|
|
105
|
+
alerts: list[str] = Field(default_factory=list)
|
|
106
|
+
confidence: float = Field(ge=0.0, le=1.0)
|
|
107
|
+
|
|
108
|
+
@field_validator("trust_score", mode="before")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _clamp_trust_score(cls, value: Any) -> int:
|
|
111
|
+
return max(0, min(100, int(value)))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PipelineStageOutputs(VerdantBaseModel):
|
|
115
|
+
intent: IntentStageOutput
|
|
116
|
+
baseline: BaselineStageOutput
|
|
117
|
+
bias: BiasStageOutput
|
|
118
|
+
explanation: ExplainStageOutput
|
|
119
|
+
trust: TrustStageOutput
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AuditPayload(VerdantBaseModel):
|
|
123
|
+
audit_id: UUID | None = None
|
|
124
|
+
request_id: UUID = Field(default_factory=uuid4)
|
|
125
|
+
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
126
|
+
context_type: ContextType
|
|
127
|
+
input_text: str
|
|
128
|
+
output_text: str
|
|
129
|
+
raw_output: Any = None
|
|
130
|
+
clean_output: Any = None
|
|
131
|
+
stages: PipelineStageOutputs
|
|
132
|
+
trust_score: int = Field(ge=0, le=100)
|
|
133
|
+
flags: list[str] = Field(default_factory=list)
|
|
134
|
+
explanation: str
|
|
135
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
136
|
+
model_name: str = "claude-sonnet-4-6"
|
|
137
|
+
duration_ms: int = 0
|
|
138
|
+
error: str | None = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WrapResult(VerdantBaseModel):
|
|
142
|
+
output: Any
|
|
143
|
+
audit: AuditPayload
|
|
144
|
+
trust_score: int = Field(ge=0, le=100)
|
|
145
|
+
flags: list[str] = Field(default_factory=list)
|
|
146
|
+
explanation: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PipelineRunRequest(VerdantBaseModel):
|
|
150
|
+
input_text: str
|
|
151
|
+
context_type: ContextType
|
|
152
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
verdant/pipeline.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from .config import Settings, get_settings
|
|
11
|
+
from .models import (
|
|
12
|
+
AuditPayload,
|
|
13
|
+
BaselineStageOutput,
|
|
14
|
+
BiasStageOutput,
|
|
15
|
+
ContextType,
|
|
16
|
+
ExplainStageOutput,
|
|
17
|
+
IntentStageOutput,
|
|
18
|
+
PipelineStageOutputs,
|
|
19
|
+
RiskLevel,
|
|
20
|
+
TrustStageOutput,
|
|
21
|
+
WrapResult,
|
|
22
|
+
)
|
|
23
|
+
from .services.cache_service import CacheService
|
|
24
|
+
from .services.claude_service import ClaudeService
|
|
25
|
+
from .services.db_service import DBService
|
|
26
|
+
from .services.gemini_service import GeminiService
|
|
27
|
+
from .stages.baseline import load_baseline
|
|
28
|
+
from .stages.bias import match_bias_patterns
|
|
29
|
+
from .stages.explain import generate_explanation
|
|
30
|
+
from .stages.intent import extract_intent
|
|
31
|
+
from .stages.trust import synthesize_trust_score
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _json_safe(value: Any) -> Any:
|
|
37
|
+
if hasattr(value, "model_dump"):
|
|
38
|
+
return value.model_dump(mode="json")
|
|
39
|
+
if isinstance(value, dict):
|
|
40
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
41
|
+
if isinstance(value, list):
|
|
42
|
+
return [_json_safe(item) for item in value]
|
|
43
|
+
if isinstance(value, tuple):
|
|
44
|
+
return [_json_safe(item) for item in value]
|
|
45
|
+
if isinstance(value, set):
|
|
46
|
+
return [_json_safe(item) for item in value]
|
|
47
|
+
return str(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stringify_output(value: Any) -> str:
|
|
51
|
+
if value is None:
|
|
52
|
+
return ""
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
return value
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
if isinstance(value.get("output"), str):
|
|
57
|
+
return value["output"]
|
|
58
|
+
if "choices" in value:
|
|
59
|
+
choices = value.get("choices") or []
|
|
60
|
+
if choices:
|
|
61
|
+
first = choices[0]
|
|
62
|
+
message = first.get("message") if isinstance(first, dict) else getattr(first, "message", None)
|
|
63
|
+
if isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
64
|
+
return message["content"]
|
|
65
|
+
if hasattr(message, "content"):
|
|
66
|
+
content = getattr(message, "content")
|
|
67
|
+
if isinstance(content, str):
|
|
68
|
+
return content
|
|
69
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
70
|
+
for attr in ("output_text", "text"):
|
|
71
|
+
attr_value = getattr(value, attr, None)
|
|
72
|
+
if isinstance(attr_value, str):
|
|
73
|
+
return attr_value
|
|
74
|
+
if hasattr(value, "model_dump"):
|
|
75
|
+
dumped = value.model_dump(mode="json")
|
|
76
|
+
if isinstance(dumped, dict):
|
|
77
|
+
return _stringify_output(dumped)
|
|
78
|
+
return str(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _derive_input_text(fn_kwargs: dict[str, Any]) -> str:
|
|
82
|
+
for key in ("input_text", "prompt", "content", "text", "message"):
|
|
83
|
+
value = fn_kwargs.get(key)
|
|
84
|
+
if isinstance(value, str) and value.strip():
|
|
85
|
+
return value
|
|
86
|
+
messages = fn_kwargs.get("messages")
|
|
87
|
+
if isinstance(messages, list) and messages:
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
for message in messages:
|
|
90
|
+
if isinstance(message, dict):
|
|
91
|
+
role = message.get("role", "user")
|
|
92
|
+
content = message.get("content", "")
|
|
93
|
+
else:
|
|
94
|
+
role = getattr(message, "role", "user")
|
|
95
|
+
content = getattr(message, "content", "")
|
|
96
|
+
parts.append(f"{role}: {content}")
|
|
97
|
+
if parts:
|
|
98
|
+
return "\n".join(parts)
|
|
99
|
+
return json.dumps(_json_safe(fn_kwargs), ensure_ascii=False, default=str)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _call_target(fn: Callable[..., Any], fn_kwargs: dict[str, Any]) -> Any:
|
|
103
|
+
result = fn(**fn_kwargs)
|
|
104
|
+
if inspect.isawaitable(result):
|
|
105
|
+
return await result
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class VerdantPipeline:
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
settings: Settings | None = None,
|
|
113
|
+
*,
|
|
114
|
+
claude_service: ClaudeService | None = None,
|
|
115
|
+
gemini_service: GeminiService | None = None,
|
|
116
|
+
cache_service: CacheService | None = None,
|
|
117
|
+
db_service: DBService | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.settings = settings or get_settings()
|
|
120
|
+
self.claude_service = claude_service or ClaudeService(self.settings)
|
|
121
|
+
self.gemini_service = gemini_service or GeminiService(self.settings)
|
|
122
|
+
self.cache_service = cache_service or CacheService(self.settings)
|
|
123
|
+
self.db_service = db_service or DBService(self.settings)
|
|
124
|
+
|
|
125
|
+
async def _generate_default_output(
|
|
126
|
+
self,
|
|
127
|
+
input_text: str,
|
|
128
|
+
intent: IntentStageOutput,
|
|
129
|
+
baseline: BaselineStageOutput,
|
|
130
|
+
metadata: dict[str, Any],
|
|
131
|
+
) -> str:
|
|
132
|
+
prompt = (
|
|
133
|
+
"Respond helpfully to the user's request and return JSON only.\n\n"
|
|
134
|
+
f"Context type: {intent.context_type.value}\n"
|
|
135
|
+
f"Intent summary: {intent.user_intent_summary}\n"
|
|
136
|
+
f"Baseline summary: {baseline.baseline_summary}\n"
|
|
137
|
+
f"Input text:\n{input_text}\n\n"
|
|
138
|
+
f"Metadata:\n{json.dumps(_json_safe(metadata), ensure_ascii=False, default=str)}\n"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return await self.claude_service.generate_text("generate", prompt)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
logger.warning("Default output generation failed, using input echo fallback: %s", exc)
|
|
145
|
+
return input_text
|
|
146
|
+
|
|
147
|
+
async def run(
|
|
148
|
+
self,
|
|
149
|
+
fn: Callable[..., Any] | None = None,
|
|
150
|
+
*,
|
|
151
|
+
context_type: str | ContextType | None = None,
|
|
152
|
+
input_text: str | None = None,
|
|
153
|
+
fn_kwargs: dict[str, Any] | None = None,
|
|
154
|
+
metadata: dict[str, Any] | None = None,
|
|
155
|
+
) -> WrapResult:
|
|
156
|
+
started_at = time.perf_counter()
|
|
157
|
+
metadata = metadata or {}
|
|
158
|
+
fn_kwargs = dict(fn_kwargs or {})
|
|
159
|
+
resolved_input_text = input_text or _derive_input_text(fn_kwargs)
|
|
160
|
+
|
|
161
|
+
intent = await extract_intent(
|
|
162
|
+
resolved_input_text,
|
|
163
|
+
context_type=context_type,
|
|
164
|
+
settings=self.settings,
|
|
165
|
+
claude_service=self.claude_service,
|
|
166
|
+
gemini_service=self.gemini_service,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
baseline_context = ContextType.normalize(context_type) if context_type else intent.context_type
|
|
170
|
+
baseline = await load_baseline(
|
|
171
|
+
baseline_context,
|
|
172
|
+
settings=self.settings,
|
|
173
|
+
db_service=self.db_service,
|
|
174
|
+
cache_service=self.cache_service,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
pipeline_error: str | None = None
|
|
178
|
+
|
|
179
|
+
if fn is None:
|
|
180
|
+
try:
|
|
181
|
+
raw_output: Any = await self._generate_default_output(resolved_input_text, intent, baseline, metadata)
|
|
182
|
+
clean_output: Any = raw_output
|
|
183
|
+
except Exception as exc: # pragma: no cover - catch-all guard
|
|
184
|
+
logger.exception("Default generation failed unexpectedly: %s", exc)
|
|
185
|
+
raw_output = {"error": str(exc)}
|
|
186
|
+
clean_output = ""
|
|
187
|
+
pipeline_error = str(exc)
|
|
188
|
+
else:
|
|
189
|
+
try:
|
|
190
|
+
raw_output = await _call_target(fn, fn_kwargs)
|
|
191
|
+
clean_output = _json_safe(raw_output)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.warning("Wrapped function raised; continuing with fallback output: %s", exc)
|
|
194
|
+
raw_output = {"error": str(exc)}
|
|
195
|
+
clean_output = ""
|
|
196
|
+
pipeline_error = str(exc)
|
|
197
|
+
|
|
198
|
+
output_text = _stringify_output(clean_output)
|
|
199
|
+
bias = await match_bias_patterns(
|
|
200
|
+
resolved_input_text,
|
|
201
|
+
output_text,
|
|
202
|
+
intent=intent,
|
|
203
|
+
baseline=baseline,
|
|
204
|
+
)
|
|
205
|
+
explanation = await generate_explanation(
|
|
206
|
+
resolved_input_text,
|
|
207
|
+
output_text,
|
|
208
|
+
intent=intent,
|
|
209
|
+
baseline=baseline,
|
|
210
|
+
bias=bias,
|
|
211
|
+
settings=self.settings,
|
|
212
|
+
claude_service=self.claude_service,
|
|
213
|
+
gemini_service=self.gemini_service,
|
|
214
|
+
)
|
|
215
|
+
trust = await synthesize_trust_score(
|
|
216
|
+
intent=intent,
|
|
217
|
+
baseline=baseline,
|
|
218
|
+
bias=bias,
|
|
219
|
+
explanation=explanation,
|
|
220
|
+
settings=self.settings,
|
|
221
|
+
db_service=self.db_service,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if pipeline_error:
|
|
225
|
+
explanation = explanation.model_copy(
|
|
226
|
+
update={
|
|
227
|
+
"plain_language_explanation": (
|
|
228
|
+
f"{explanation.plain_language_explanation} "
|
|
229
|
+
f"The wrapped AI call failed: {pipeline_error}."
|
|
230
|
+
).strip(),
|
|
231
|
+
"caveats": explanation.caveats + ["No valid upstream output was produced."],
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
trust = trust.model_copy(
|
|
235
|
+
update={
|
|
236
|
+
"trust_score": min(trust.trust_score, 15),
|
|
237
|
+
"risk_level": RiskLevel.critical,
|
|
238
|
+
"alerts": list(dict.fromkeys(trust.alerts + ["Wrapped function call failed"])),
|
|
239
|
+
"reasons": trust.reasons + [f"Upstream call failed: {pipeline_error}."],
|
|
240
|
+
"score_breakdown": {**trust.score_breakdown, "upstream_call_failed": 0.0},
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
stages = PipelineStageOutputs(
|
|
245
|
+
intent=intent,
|
|
246
|
+
baseline=baseline,
|
|
247
|
+
bias=bias,
|
|
248
|
+
explanation=explanation,
|
|
249
|
+
trust=trust,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
audit = AuditPayload(
|
|
253
|
+
context_type=ContextType.normalize(intent.context_type),
|
|
254
|
+
input_text=resolved_input_text,
|
|
255
|
+
output_text=output_text,
|
|
256
|
+
raw_output=_json_safe(raw_output),
|
|
257
|
+
clean_output=_json_safe(clean_output),
|
|
258
|
+
stages=stages,
|
|
259
|
+
trust_score=trust.trust_score,
|
|
260
|
+
flags=list(bias.flags),
|
|
261
|
+
explanation=explanation.plain_language_explanation,
|
|
262
|
+
metadata=_json_safe(metadata),
|
|
263
|
+
model_name=self.settings.claude_model,
|
|
264
|
+
duration_ms=int((time.perf_counter() - started_at) * 1000),
|
|
265
|
+
error=raw_output.get("error") if isinstance(raw_output, dict) else None,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return WrapResult(
|
|
269
|
+
output=clean_output,
|
|
270
|
+
audit=audit,
|
|
271
|
+
trust_score=trust.trust_score,
|
|
272
|
+
flags=list(bias.flags),
|
|
273
|
+
explanation=explanation.plain_language_explanation,
|
|
274
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Service package marker.
|