ragops 1.6.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.
- ragops/__init__.py +16 -0
- ragops/adapters/__init__.py +5 -0
- ragops/adapters/http.py +34 -0
- ragops/benchmarks.py +25 -0
- ragops/cli.py +207 -0
- ragops/config.py +14 -0
- ragops/control_plane.py +125 -0
- ragops/demo.py +162 -0
- ragops/engine.py +149 -0
- ragops/evaluators.py +70 -0
- ragops/loader.py +196 -0
- ragops/models.py +131 -0
- ragops/plugins.py +117 -0
- ragops/providers/__init__.py +5 -0
- ragops/providers/openai_responses.py +84 -0
- ragops/reporters.py +124 -0
- ragops/store.py +170 -0
- ragops/traces.py +52 -0
- ragops-1.6.0.dist-info/METADATA +220 -0
- ragops-1.6.0.dist-info/RECORD +23 -0
- ragops-1.6.0.dist-info/WHEEL +4 -0
- ragops-1.6.0.dist-info/entry_points.txt +2 -0
- ragops-1.6.0.dist-info/licenses/LICENSE +21 -0
ragops/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""RAGOps public API."""
|
|
2
|
+
|
|
3
|
+
from ragops.engine import compare, evaluate
|
|
4
|
+
from ragops.loader import load_responses, load_scenario, responses_from_data, scenario_from_dict
|
|
5
|
+
|
|
6
|
+
__version__ = "1.6.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"compare",
|
|
10
|
+
"evaluate",
|
|
11
|
+
"load_responses",
|
|
12
|
+
"load_scenario",
|
|
13
|
+
"responses_from_data",
|
|
14
|
+
"scenario_from_dict",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
ragops/adapters/http.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdapterError(RuntimeError):
|
|
10
|
+
"""Raised when a remote application adapter cannot return valid JSON."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def post_json(
|
|
14
|
+
url: str,
|
|
15
|
+
payload: dict[str, Any],
|
|
16
|
+
*,
|
|
17
|
+
headers: dict[str, str] | None = None,
|
|
18
|
+
timeout: float = 30.0,
|
|
19
|
+
) -> dict[str, Any]:
|
|
20
|
+
request_headers = {"content-type": "application/json", **(headers or {})}
|
|
21
|
+
request = urllib.request.Request(
|
|
22
|
+
url,
|
|
23
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
24
|
+
headers=request_headers,
|
|
25
|
+
method="POST",
|
|
26
|
+
)
|
|
27
|
+
try:
|
|
28
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
29
|
+
data = json.loads(response.read().decode("utf-8"))
|
|
30
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
31
|
+
raise AdapterError(f"HTTP adapter failed for {url}: {exc}") from exc
|
|
32
|
+
if not isinstance(data, dict):
|
|
33
|
+
raise AdapterError(f"HTTP adapter expected a JSON object from {url}")
|
|
34
|
+
return data
|
ragops/benchmarks.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ragops.models import Scenario
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scenario_summary(scenario: Scenario) -> dict[str, Any]:
|
|
10
|
+
"""Return deterministic benchmark coverage metadata for authoring and review."""
|
|
11
|
+
categories = Counter(case.category for case in scenario.cases)
|
|
12
|
+
severities = Counter(case.severity for case in scenario.cases)
|
|
13
|
+
languages = Counter(case.language for case in scenario.cases)
|
|
14
|
+
attacks = Counter(
|
|
15
|
+
case.attack_category for case in scenario.cases if case.attack_category is not None
|
|
16
|
+
)
|
|
17
|
+
return {
|
|
18
|
+
"schema_version": scenario.schema_version,
|
|
19
|
+
"scenario_id": scenario.id,
|
|
20
|
+
"case_count": len(scenario.cases),
|
|
21
|
+
"categories": dict(sorted(categories.items())),
|
|
22
|
+
"severities": dict(sorted(severities.items())),
|
|
23
|
+
"languages": dict(sorted(languages.items())),
|
|
24
|
+
"attack_categories": dict(sorted(attacks.items())),
|
|
25
|
+
}
|
ragops/cli.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ragops.benchmarks import scenario_summary
|
|
8
|
+
from ragops.config import load_regression_policy
|
|
9
|
+
from ragops.control_plane import ControlPlane
|
|
10
|
+
from ragops.demo import write_demo
|
|
11
|
+
from ragops.engine import compare, evaluate
|
|
12
|
+
from ragops.loader import ContractError, load_responses, load_scenario
|
|
13
|
+
from ragops.plugins import (
|
|
14
|
+
CaseEvaluator,
|
|
15
|
+
CitationCorrectnessEvaluator,
|
|
16
|
+
ClaimSupportEvaluator,
|
|
17
|
+
RetrievalRecallEvaluator,
|
|
18
|
+
)
|
|
19
|
+
from ragops.reporters import comparison_html, comparison_markdown, evaluation_markdown
|
|
20
|
+
from ragops.store import ExperimentStore
|
|
21
|
+
from ragops.traces import load_trace_jsonl
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="ragops")
|
|
26
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
demo_parser = commands.add_parser("demo", help="Generate a credential-free release-gate demo")
|
|
28
|
+
demo_parser.add_argument("--output", default="ragops-demo")
|
|
29
|
+
demo_parser.add_argument(
|
|
30
|
+
"--force",
|
|
31
|
+
action="store_true",
|
|
32
|
+
help="Replace regular demo files in an existing non-symlink directory",
|
|
33
|
+
)
|
|
34
|
+
evaluate_parser = commands.add_parser("evaluate", help="Evaluate recorded responses")
|
|
35
|
+
evaluate_parser.add_argument("--scenario", required=True)
|
|
36
|
+
evaluate_input = evaluate_parser.add_mutually_exclusive_group(required=True)
|
|
37
|
+
evaluate_input.add_argument("--responses")
|
|
38
|
+
evaluate_input.add_argument("--traces", help="Portable JSONL trace input")
|
|
39
|
+
evaluate_parser.add_argument("--output")
|
|
40
|
+
evaluate_parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
|
41
|
+
evaluate_parser.add_argument("--store")
|
|
42
|
+
evaluate_parser.add_argument("--label", default="")
|
|
43
|
+
evaluate_parser.add_argument(
|
|
44
|
+
"--evaluator",
|
|
45
|
+
action="append",
|
|
46
|
+
choices=("retrieval_recall", "citation_correctness", "claim_support"),
|
|
47
|
+
default=[],
|
|
48
|
+
)
|
|
49
|
+
compare_parser = commands.add_parser("compare", help="Compare candidate responses to baseline")
|
|
50
|
+
compare_parser.add_argument("--scenario", required=True)
|
|
51
|
+
baseline_input = compare_parser.add_mutually_exclusive_group(required=True)
|
|
52
|
+
baseline_input.add_argument("--baseline")
|
|
53
|
+
baseline_input.add_argument("--baseline-traces")
|
|
54
|
+
candidate_input = compare_parser.add_mutually_exclusive_group(required=True)
|
|
55
|
+
candidate_input.add_argument("--candidate")
|
|
56
|
+
candidate_input.add_argument("--candidate-traces")
|
|
57
|
+
compare_parser.add_argument("--output")
|
|
58
|
+
compare_parser.add_argument("--format", choices=("json", "markdown", "html"), default="markdown")
|
|
59
|
+
compare_parser.add_argument("--store")
|
|
60
|
+
compare_parser.add_argument("--label", default="")
|
|
61
|
+
compare_parser.add_argument("--policy", help="TOML regression policy")
|
|
62
|
+
history_parser = commands.add_parser("history", help="List saved experiment runs")
|
|
63
|
+
history_parser.add_argument("--store", required=True)
|
|
64
|
+
history_parser.add_argument("--limit", type=int, default=20)
|
|
65
|
+
review_parser = commands.add_parser("review", help="Review a saved experiment run")
|
|
66
|
+
review_parser.add_argument("--store", required=True)
|
|
67
|
+
review_parser.add_argument("--run-id", required=True)
|
|
68
|
+
review_parser.add_argument(
|
|
69
|
+
"--status", required=True, choices=("accepted", "rejected", "needs_changes")
|
|
70
|
+
)
|
|
71
|
+
review_parser.add_argument("--reviewer", required=True)
|
|
72
|
+
review_parser.add_argument("--note", default="")
|
|
73
|
+
trend_parser = commands.add_parser("trend", help="Read a saved metric trend")
|
|
74
|
+
trend_parser.add_argument("--store", required=True)
|
|
75
|
+
trend_parser.add_argument("--scenario-id", required=True)
|
|
76
|
+
trend_parser.add_argument("--metric", required=True)
|
|
77
|
+
trend_parser.add_argument("--limit", type=int, default=50)
|
|
78
|
+
inspect_parser = commands.add_parser("inspect", help="Inspect scenario benchmark coverage")
|
|
79
|
+
inspect_parser.add_argument("--scenario", required=True)
|
|
80
|
+
workspace_create = commands.add_parser("workspace-create", help="Create an alpha workspace")
|
|
81
|
+
workspace_create.add_argument("--root", required=True)
|
|
82
|
+
workspace_create.add_argument("--workspace-id", required=True)
|
|
83
|
+
workspace_create.add_argument("--name", required=True)
|
|
84
|
+
workspace_rotate = commands.add_parser("workspace-rotate-key", help="Rotate a workspace key")
|
|
85
|
+
workspace_rotate.add_argument("--root", required=True)
|
|
86
|
+
workspace_rotate.add_argument("--workspace-id", required=True)
|
|
87
|
+
workspace_rotate.add_argument("--current-key", required=True)
|
|
88
|
+
workspace_audit = commands.add_parser("workspace-audit", help="Read workspace audit events")
|
|
89
|
+
workspace_audit.add_argument("--root", required=True)
|
|
90
|
+
workspace_audit.add_argument("--workspace-id", required=True)
|
|
91
|
+
workspace_audit.add_argument("--limit", type=int, default=100)
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
args = build_parser().parse_args()
|
|
97
|
+
if args.command == "demo":
|
|
98
|
+
try:
|
|
99
|
+
summary = write_demo(args.output, force=args.force)
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise SystemExit(f"demo output error: {exc}") from exc
|
|
102
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
103
|
+
return 0
|
|
104
|
+
if args.command == "inspect":
|
|
105
|
+
try:
|
|
106
|
+
summary = scenario_summary(load_scenario(args.scenario))
|
|
107
|
+
except ContractError as exc:
|
|
108
|
+
raise SystemExit(f"contract error: {exc}") from exc
|
|
109
|
+
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
110
|
+
return 0
|
|
111
|
+
if args.command == "workspace-create":
|
|
112
|
+
try:
|
|
113
|
+
key = ControlPlane(args.root).create_workspace(args.workspace_id, args.name)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise SystemExit(str(exc)) from exc
|
|
116
|
+
print(json.dumps({"workspace_id": args.workspace_id, "api_key": key}))
|
|
117
|
+
return 0
|
|
118
|
+
if args.command == "workspace-rotate-key":
|
|
119
|
+
try:
|
|
120
|
+
key = ControlPlane(args.root).rotate_key(args.workspace_id, args.current_key)
|
|
121
|
+
except (PermissionError, ValueError) as exc:
|
|
122
|
+
raise SystemExit(str(exc)) from exc
|
|
123
|
+
print(json.dumps({"workspace_id": args.workspace_id, "api_key": key}))
|
|
124
|
+
return 0
|
|
125
|
+
if args.command == "workspace-audit":
|
|
126
|
+
events = ControlPlane(args.root).audit_events(args.workspace_id, limit=args.limit)
|
|
127
|
+
print(json.dumps(events, ensure_ascii=False, indent=2))
|
|
128
|
+
return 0
|
|
129
|
+
if args.command == "history":
|
|
130
|
+
runs = ExperimentStore(args.store).list_runs(limit=args.limit)
|
|
131
|
+
print(json.dumps(runs, ensure_ascii=False, indent=2))
|
|
132
|
+
return 0
|
|
133
|
+
if args.command == "review":
|
|
134
|
+
try:
|
|
135
|
+
ExperimentStore(args.store).review(
|
|
136
|
+
args.run_id,
|
|
137
|
+
status=args.status,
|
|
138
|
+
reviewer=args.reviewer,
|
|
139
|
+
note=args.note,
|
|
140
|
+
)
|
|
141
|
+
except (KeyError, ValueError) as exc:
|
|
142
|
+
raise SystemExit(str(exc)) from exc
|
|
143
|
+
print(json.dumps({"run_id": args.run_id, "review_status": args.status}))
|
|
144
|
+
return 0
|
|
145
|
+
if args.command == "trend":
|
|
146
|
+
points = ExperimentStore(args.store).metric_trend(
|
|
147
|
+
args.scenario_id,
|
|
148
|
+
args.metric,
|
|
149
|
+
limit=args.limit,
|
|
150
|
+
)
|
|
151
|
+
print(json.dumps(points, ensure_ascii=False, indent=2))
|
|
152
|
+
return 0
|
|
153
|
+
try:
|
|
154
|
+
scenario = load_scenario(args.scenario)
|
|
155
|
+
if args.command == "evaluate":
|
|
156
|
+
report = evaluate(
|
|
157
|
+
scenario,
|
|
158
|
+
load_trace_jsonl(args.traces) if args.traces else load_responses(args.responses),
|
|
159
|
+
evaluators=_evaluators_from_names(args.evaluator),
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
report = compare(
|
|
163
|
+
scenario,
|
|
164
|
+
(
|
|
165
|
+
load_trace_jsonl(args.baseline_traces)
|
|
166
|
+
if args.baseline_traces
|
|
167
|
+
else load_responses(args.baseline)
|
|
168
|
+
),
|
|
169
|
+
(
|
|
170
|
+
load_trace_jsonl(args.candidate_traces)
|
|
171
|
+
if args.candidate_traces
|
|
172
|
+
else load_responses(args.candidate)
|
|
173
|
+
),
|
|
174
|
+
policy=load_regression_policy(args.policy) if args.policy else None,
|
|
175
|
+
)
|
|
176
|
+
except (ContractError, ValueError) as exc:
|
|
177
|
+
raise SystemExit(f"contract error: {exc}") from exc
|
|
178
|
+
if args.format == "json":
|
|
179
|
+
rendered = json.dumps(report.to_dict(), ensure_ascii=False, indent=2)
|
|
180
|
+
elif args.command == "evaluate":
|
|
181
|
+
rendered = evaluation_markdown(report)
|
|
182
|
+
elif args.format == "html":
|
|
183
|
+
rendered = comparison_html(report)
|
|
184
|
+
else:
|
|
185
|
+
rendered = comparison_markdown(report)
|
|
186
|
+
if args.store:
|
|
187
|
+
ExperimentStore(args.store).save(report, label=args.label)
|
|
188
|
+
if args.output:
|
|
189
|
+
output_path = Path(args.output)
|
|
190
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
output_path.write_text(rendered.rstrip() + "\n", encoding="utf-8")
|
|
192
|
+
else:
|
|
193
|
+
print(rendered)
|
|
194
|
+
return 0 if report.passed else 2
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _evaluators_from_names(names: list[str]) -> tuple[CaseEvaluator, ...]:
|
|
198
|
+
factories = {
|
|
199
|
+
"retrieval_recall": RetrievalRecallEvaluator,
|
|
200
|
+
"citation_correctness": CitationCorrectnessEvaluator,
|
|
201
|
+
"claim_support": ClaimSupportEvaluator,
|
|
202
|
+
}
|
|
203
|
+
return tuple(factories[name]() for name in names)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
raise SystemExit(main())
|
ragops/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ragops.models import RegressionPolicy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_regression_policy(path: str | Path) -> RegressionPolicy:
|
|
10
|
+
try:
|
|
11
|
+
data = tomllib.loads(Path(path).read_text(encoding="utf-8"))
|
|
12
|
+
return RegressionPolicy(**data["regression"])
|
|
13
|
+
except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as exc:
|
|
14
|
+
raise ValueError(f"Invalid regression policy {path}: {exc}") from exc
|
ragops/control_plane.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import sqlite3
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ragops.store import ExperimentStore
|
|
12
|
+
|
|
13
|
+
WORKSPACE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{2,62}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ControlPlane:
|
|
17
|
+
"""Local control-plane alpha with workspace-isolated experiment stores."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, root: str | Path) -> None:
|
|
20
|
+
self.root = Path(root)
|
|
21
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
self.index = self.root / "control-plane.db"
|
|
23
|
+
self._initialize()
|
|
24
|
+
|
|
25
|
+
def create_workspace(self, workspace_id: str, name: str) -> str:
|
|
26
|
+
if not WORKSPACE_ID.fullmatch(workspace_id):
|
|
27
|
+
raise ValueError("workspace ID must be a lowercase slug of 3-63 characters")
|
|
28
|
+
if not name.strip():
|
|
29
|
+
raise ValueError("workspace name is required")
|
|
30
|
+
api_key = f"rgo_{secrets.token_urlsafe(32)}"
|
|
31
|
+
digest = _digest(api_key)
|
|
32
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
33
|
+
try:
|
|
34
|
+
with self._connect() as connection:
|
|
35
|
+
connection.execute(
|
|
36
|
+
"INSERT INTO workspaces (id, name, key_digest, created_at) VALUES (?, ?, ?, ?)",
|
|
37
|
+
(workspace_id, name, digest, created_at),
|
|
38
|
+
)
|
|
39
|
+
connection.execute(
|
|
40
|
+
"INSERT INTO audit_events (created_at, workspace_id, action, detail) VALUES (?, ?, ?, ?)",
|
|
41
|
+
(created_at, workspace_id, "workspace.created", name),
|
|
42
|
+
)
|
|
43
|
+
except sqlite3.IntegrityError as exc:
|
|
44
|
+
raise ValueError(f"workspace already exists: {workspace_id}") from exc
|
|
45
|
+
return api_key
|
|
46
|
+
|
|
47
|
+
def authenticate(self, workspace_id: str, api_key: str) -> bool:
|
|
48
|
+
with self._connect() as connection:
|
|
49
|
+
row = connection.execute(
|
|
50
|
+
"SELECT key_digest FROM workspaces WHERE id = ?", (workspace_id,)
|
|
51
|
+
).fetchone()
|
|
52
|
+
return bool(row and hmac.compare_digest(row[0], _digest(api_key)))
|
|
53
|
+
|
|
54
|
+
def workspace_store(self, workspace_id: str, api_key: str) -> ExperimentStore:
|
|
55
|
+
if not self.authenticate(workspace_id, api_key):
|
|
56
|
+
raise PermissionError("invalid workspace credentials")
|
|
57
|
+
path = self.root / "workspaces" / workspace_id / "runs.db"
|
|
58
|
+
self.audit(workspace_id, "workspace.store_accessed", "experiment store")
|
|
59
|
+
return ExperimentStore(path)
|
|
60
|
+
|
|
61
|
+
def rotate_key(self, workspace_id: str, current_key: str) -> str:
|
|
62
|
+
if not self.authenticate(workspace_id, current_key):
|
|
63
|
+
raise PermissionError("invalid workspace credentials")
|
|
64
|
+
new_key = f"rgo_{secrets.token_urlsafe(32)}"
|
|
65
|
+
with self._connect() as connection:
|
|
66
|
+
connection.execute(
|
|
67
|
+
"UPDATE workspaces SET key_digest = ? WHERE id = ?",
|
|
68
|
+
(_digest(new_key), workspace_id),
|
|
69
|
+
)
|
|
70
|
+
self.audit(workspace_id, "workspace.key_rotated", "credential rotated")
|
|
71
|
+
return new_key
|
|
72
|
+
|
|
73
|
+
def audit(self, workspace_id: str, action: str, detail: str) -> None:
|
|
74
|
+
with self._connect() as connection:
|
|
75
|
+
connection.execute(
|
|
76
|
+
"INSERT INTO audit_events (created_at, workspace_id, action, detail) VALUES (?, ?, ?, ?)",
|
|
77
|
+
(datetime.now(timezone.utc).isoformat(), workspace_id, action, detail),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def audit_events(self, workspace_id: str, *, limit: int = 100) -> list[dict[str, str]]:
|
|
81
|
+
if limit < 1 or limit > 1000:
|
|
82
|
+
raise ValueError("limit must be between 1 and 1000")
|
|
83
|
+
with self._connect() as connection:
|
|
84
|
+
rows = connection.execute(
|
|
85
|
+
"""
|
|
86
|
+
SELECT created_at, action, detail FROM audit_events
|
|
87
|
+
WHERE workspace_id = ? ORDER BY id DESC LIMIT ?
|
|
88
|
+
""",
|
|
89
|
+
(workspace_id, limit),
|
|
90
|
+
).fetchall()
|
|
91
|
+
return [
|
|
92
|
+
{"created_at": row[0], "action": row[1], "detail": row[2]} for row in rows
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
def _connect(self) -> sqlite3.Connection:
|
|
96
|
+
return sqlite3.connect(self.index)
|
|
97
|
+
|
|
98
|
+
def _initialize(self) -> None:
|
|
99
|
+
with self._connect() as connection:
|
|
100
|
+
connection.execute(
|
|
101
|
+
"""
|
|
102
|
+
CREATE TABLE IF NOT EXISTS workspaces (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
name TEXT NOT NULL,
|
|
105
|
+
key_digest TEXT NOT NULL,
|
|
106
|
+
created_at TEXT NOT NULL
|
|
107
|
+
)
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
connection.execute(
|
|
111
|
+
"""
|
|
112
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
113
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
workspace_id TEXT NOT NULL,
|
|
116
|
+
action TEXT NOT NULL,
|
|
117
|
+
detail TEXT NOT NULL,
|
|
118
|
+
FOREIGN KEY(workspace_id) REFERENCES workspaces(id)
|
|
119
|
+
)
|
|
120
|
+
"""
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _digest(api_key: str) -> str:
|
|
125
|
+
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
ragops/demo.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from ragops.engine import compare
|
|
9
|
+
from ragops.loader import responses_from_data, scenario_from_dict
|
|
10
|
+
from ragops.reporters import comparison_html, comparison_markdown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
DEMO_SCENARIO = {
|
|
14
|
+
"schema_version": "0.2",
|
|
15
|
+
"id": "ragops-five-minute-demo",
|
|
16
|
+
"name": "RAGOps five-minute release-gate demo",
|
|
17
|
+
"thresholds": {
|
|
18
|
+
"citation_coverage": 1.0,
|
|
19
|
+
"citation_precision": 1.0,
|
|
20
|
+
"lexical_groundedness": 0.7,
|
|
21
|
+
"max_latency_ms": 3000,
|
|
22
|
+
"max_cost_usd": 0.03,
|
|
23
|
+
},
|
|
24
|
+
"redteam": {
|
|
25
|
+
"forbidden_output_terms": ["DEMO_SECRET"],
|
|
26
|
+
"require_human_approval_for_external_actions": True,
|
|
27
|
+
"external_action_markers": ["sent email"],
|
|
28
|
+
},
|
|
29
|
+
"cases": [
|
|
30
|
+
{
|
|
31
|
+
"id": "demo-procedure",
|
|
32
|
+
"question": "A1000型でE-42が発生した場合の一次対応は?",
|
|
33
|
+
"evidence": [
|
|
34
|
+
"Stop A1000, inspect the cooling fan guard, remove debris, and restart below 60C."
|
|
35
|
+
],
|
|
36
|
+
"required_citation_ids": ["manual-a1000-e42"],
|
|
37
|
+
"category": "direct_procedure",
|
|
38
|
+
"severity": "high",
|
|
39
|
+
"language": "ja",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"id": "demo-escalation",
|
|
43
|
+
"question": "E-42をいつエスカレーションする?",
|
|
44
|
+
"evidence": [
|
|
45
|
+
"Escalate E-42 after two repeats within 24 hours or temperature remaining above 60C."
|
|
46
|
+
],
|
|
47
|
+
"required_citation_ids": ["policy-e42-escalation"],
|
|
48
|
+
"category": "escalation_decision",
|
|
49
|
+
"severity": "high",
|
|
50
|
+
"language": "ja",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
DEMO_BASELINE = [
|
|
56
|
+
{
|
|
57
|
+
"case_id": "demo-procedure",
|
|
58
|
+
"answer": "Stop A1000, inspect the cooling fan guard, remove debris, and restart below 60C.",
|
|
59
|
+
"citation_ids": ["manual-a1000-e42"],
|
|
60
|
+
"latency_ms": 850,
|
|
61
|
+
"cost_usd": 0.008,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"case_id": "demo-escalation",
|
|
65
|
+
"answer": "Escalate E-42 after two repeats within 24 hours or temperature remaining above 60C.",
|
|
66
|
+
"citation_ids": ["policy-e42-escalation"],
|
|
67
|
+
"latency_ms": 900,
|
|
68
|
+
"cost_usd": 0.009,
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
DEMO_CANDIDATE = [
|
|
73
|
+
DEMO_BASELINE[0],
|
|
74
|
+
{
|
|
75
|
+
"case_id": "demo-escalation",
|
|
76
|
+
"answer": "Escalate whenever the issue seems serious.",
|
|
77
|
+
"citation_ids": [],
|
|
78
|
+
"latency_ms": 920,
|
|
79
|
+
"cost_usd": 0.009,
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def write_demo(output_dir: str | Path, *, force: bool = False) -> dict[str, object]:
|
|
85
|
+
"""Write a credential-free demo bundle and return its release summary."""
|
|
86
|
+
|
|
87
|
+
destination = Path(output_dir)
|
|
88
|
+
if destination.is_symlink():
|
|
89
|
+
raise FileExistsError(f"refusing symlinked demo output directory: {destination}")
|
|
90
|
+
if destination.exists():
|
|
91
|
+
if not force:
|
|
92
|
+
raise FileExistsError(
|
|
93
|
+
f"demo output already exists: {destination}; pass --force to replace regular files"
|
|
94
|
+
)
|
|
95
|
+
if not destination.is_dir():
|
|
96
|
+
raise NotADirectoryError(f"demo output is not a directory: {destination}")
|
|
97
|
+
else:
|
|
98
|
+
destination.mkdir(parents=True, exist_ok=False)
|
|
99
|
+
scenario = scenario_from_dict(DEMO_SCENARIO)
|
|
100
|
+
report = compare(
|
|
101
|
+
scenario,
|
|
102
|
+
responses_from_data(DEMO_BASELINE),
|
|
103
|
+
responses_from_data(DEMO_CANDIDATE),
|
|
104
|
+
)
|
|
105
|
+
files = {
|
|
106
|
+
"scenario": destination / "scenario.json",
|
|
107
|
+
"baseline": destination / "baseline.json",
|
|
108
|
+
"candidate": destination / "candidate.json",
|
|
109
|
+
"markdown_report": destination / "release-report.md",
|
|
110
|
+
"html_report": destination / "release-report.html",
|
|
111
|
+
}
|
|
112
|
+
_write_demo_file(
|
|
113
|
+
files["scenario"],
|
|
114
|
+
json.dumps(DEMO_SCENARIO, ensure_ascii=False, indent=2) + "\n",
|
|
115
|
+
force=force,
|
|
116
|
+
)
|
|
117
|
+
_write_demo_file(
|
|
118
|
+
files["baseline"],
|
|
119
|
+
json.dumps(DEMO_BASELINE, ensure_ascii=False, indent=2) + "\n",
|
|
120
|
+
force=force,
|
|
121
|
+
)
|
|
122
|
+
_write_demo_file(
|
|
123
|
+
files["candidate"],
|
|
124
|
+
json.dumps(DEMO_CANDIDATE, ensure_ascii=False, indent=2) + "\n",
|
|
125
|
+
force=force,
|
|
126
|
+
)
|
|
127
|
+
_write_demo_file(files["markdown_report"], comparison_markdown(report), force=force)
|
|
128
|
+
_write_demo_file(files["html_report"], comparison_html(report), force=force)
|
|
129
|
+
return {
|
|
130
|
+
"demo_completed": True,
|
|
131
|
+
"candidate_decision": "BLOCK" if not report.passed else "PASS",
|
|
132
|
+
"failed_gates": list(report.failed_gates),
|
|
133
|
+
"output_dir": str(destination),
|
|
134
|
+
"files": {name: str(path) for name, path in files.items()},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _write_demo_file(path: Path, content: str, *, force: bool) -> None:
|
|
139
|
+
if path.is_symlink():
|
|
140
|
+
raise FileExistsError(f"refusing symlinked demo output file: {path}")
|
|
141
|
+
if not force:
|
|
142
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
143
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
144
|
+
flags |= os.O_NOFOLLOW
|
|
145
|
+
descriptor = os.open(path, flags, 0o644)
|
|
146
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
147
|
+
handle.write(content)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if path.exists() and not path.is_file():
|
|
151
|
+
raise FileExistsError(f"refusing non-file demo output target: {path}")
|
|
152
|
+
temporary = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
153
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
154
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
155
|
+
flags |= os.O_NOFOLLOW
|
|
156
|
+
descriptor = os.open(temporary, flags, 0o644)
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
159
|
+
handle.write(content)
|
|
160
|
+
os.replace(temporary, path)
|
|
161
|
+
finally:
|
|
162
|
+
temporary.unlink(missing_ok=True)
|