draft-protocol 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/PKG-INFO +1 -1
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/pyproject.toml +1 -1
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/__init__.py +1 -1
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/engine.py +89 -1
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/storage.py +19 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/tests/test_draft_protocol.py +198 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.dockerignore +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.editorconfig +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/CODEOWNERS +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/dependabot.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/workflows/ci.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.github/workflows/release.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.gitignore +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/.pre-commit-config.yaml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/AGENTS.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/BENCHMARKS.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/CHANGELOG.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/CODE_OF_CONDUCT.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/CONFORMANCE.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/CONTRIBUTING.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/Dockerfile +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/INTEGRATIONS.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/LICENSE +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/METHODOLOGY.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/Makefile +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/README.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/RELEASING.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/ROADMAP.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/RULES.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/SECURITY.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/STRUCTURE.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/THREAT_MODEL.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/docker-compose.example.yml +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/docs/README.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/docs/api.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/docs/architecture.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/examples/README.md +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/examples/basic_usage.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/background.js +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/content.css +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/content.js +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/icons/icon128.png +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/icons/icon16.png +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/icons/icon48.png +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/manifest.json +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/popup.html +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/popup.js +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/sidepanel.html +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/extension/sidepanel.js +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/__main__.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/config.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/providers.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/py.typed +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/rest.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/src/draft_protocol/server.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/tests/conftest.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/tests/test_rest.py +0 -0
- {draft_protocol-1.0.0 → draft_protocol-1.1.0}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: draft-protocol
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: DRAFT Protocol — Intake governance for AI tool calls. Ensures AI understands human intent before execution begins.
|
|
5
5
|
Project-URL: Homepage, https://github.com/manifold-vectors/draft-protocol
|
|
6
6
|
Project-URL: Documentation, https://github.com/manifold-vectors/draft-protocol#readme
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "draft-protocol"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "DRAFT Protocol — Intake governance for AI tool calls. Ensures AI understands human intent before execution begins."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -6,6 +6,9 @@ Features:
|
|
|
6
6
|
3. Context-aware suggestions — optional LLM scaffolds or static templates
|
|
7
7
|
4. Classification confidence scoring — 0.0-1.0 on all assessments
|
|
8
8
|
5. Graceful degradation — works without any LLM, better with one
|
|
9
|
+
6. Closed session guards — all operations reject closed sessions (M1.3)
|
|
10
|
+
7. Tier enum validation — rejects invalid tier strings (M1.4)
|
|
11
|
+
8. Context enrichment — gate PASS returns full dimensional mapping (M1.5)
|
|
9
12
|
|
|
10
13
|
Supports any LLM provider via DRAFT_LLM_PROVIDER env var:
|
|
11
14
|
ollama, openai (+ compatible APIs), anthropic, or none (keyword-only).
|
|
@@ -23,6 +26,18 @@ from draft_protocol.config import (
|
|
|
23
26
|
STANDARD_TRIGGERS,
|
|
24
27
|
)
|
|
25
28
|
|
|
29
|
+
# ── M1.3: Closed Session Guard ───────────────────────────
|
|
30
|
+
|
|
31
|
+
_CLOSED_SESSION_ERROR = "Session {sid} is closed. Start new session with draft_intake."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_open(session_id: str) -> dict | None:
|
|
35
|
+
"""Return error dict if session is closed or missing, else None."""
|
|
36
|
+
if storage.is_session_closed(session_id):
|
|
37
|
+
return {"error": _CLOSED_SESSION_ERROR.format(sid=session_id)}
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
26
41
|
# ── LLM Schemas ───────────────────────────────────────────
|
|
27
42
|
|
|
28
43
|
TIER_SCHEMA = {
|
|
@@ -218,6 +233,11 @@ def _field_enrichment(field_key: str) -> str:
|
|
|
218
233
|
|
|
219
234
|
def map_dimensions(session_id: str, context: str) -> dict:
|
|
220
235
|
"""Map DRAFT dimensions against user context using LLM or heuristics."""
|
|
236
|
+
# M1.3: Closed session guard
|
|
237
|
+
closed = _check_open(session_id)
|
|
238
|
+
if closed:
|
|
239
|
+
return closed
|
|
240
|
+
|
|
221
241
|
session = storage.get_session(session_id)
|
|
222
242
|
if not session:
|
|
223
243
|
return {"error": f"Session {session_id} not found"}
|
|
@@ -388,6 +408,11 @@ def _context_suggests_applicable(dim_key: str, context: str) -> bool:
|
|
|
388
408
|
|
|
389
409
|
def generate_elicitation(session_id: str) -> list[dict]:
|
|
390
410
|
"""Generate targeted questions for MISSING/AMBIGUOUS fields."""
|
|
411
|
+
# M1.3: Closed session guard
|
|
412
|
+
closed = _check_open(session_id)
|
|
413
|
+
if closed:
|
|
414
|
+
return [closed]
|
|
415
|
+
|
|
391
416
|
session = storage.get_session(session_id)
|
|
392
417
|
if not session:
|
|
393
418
|
return [{"error": f"Session {session_id} not found"}]
|
|
@@ -465,6 +490,11 @@ def _suggest_answer(field_key: str, intent: str) -> str | None:
|
|
|
465
490
|
|
|
466
491
|
def generate_assumptions(session_id: str) -> list[dict]:
|
|
467
492
|
"""Surface key assumptions as falsifiable claims."""
|
|
493
|
+
# M1.3: Closed session guard
|
|
494
|
+
closed = _check_open(session_id)
|
|
495
|
+
if closed:
|
|
496
|
+
return [closed]
|
|
497
|
+
|
|
468
498
|
session = storage.get_session(session_id)
|
|
469
499
|
if not session:
|
|
470
500
|
return [{"error": f"Session {session_id} not found"}]
|
|
@@ -508,6 +538,11 @@ def generate_assumptions(session_id: str) -> list[dict]:
|
|
|
508
538
|
|
|
509
539
|
def check_gate(session_id: str) -> dict:
|
|
510
540
|
"""Check whether all applicable fields are confirmed."""
|
|
541
|
+
# M1.3: Closed session guard
|
|
542
|
+
closed = _check_open(session_id)
|
|
543
|
+
if closed:
|
|
544
|
+
return {"passed": False, "blockers": [closed["error"]], "summary": "ERROR"}
|
|
545
|
+
|
|
511
546
|
session = storage.get_session(session_id)
|
|
512
547
|
if not session:
|
|
513
548
|
return {"passed": False, "blockers": ["Session not found"], "summary": "ERROR"}
|
|
@@ -554,7 +589,7 @@ def check_gate(session_id: str) -> dict:
|
|
|
554
589
|
|
|
555
590
|
storage.log_audit(session_id, "draft_gate", "gate_check", f"{'PASS' if passed else 'FAIL'}: {confirmed}/{total}")
|
|
556
591
|
|
|
557
|
-
|
|
592
|
+
result = {
|
|
558
593
|
"passed": passed,
|
|
559
594
|
"confirmed": confirmed,
|
|
560
595
|
"total": total,
|
|
@@ -562,12 +597,40 @@ def check_gate(session_id: str) -> dict:
|
|
|
562
597
|
"summary": f"{'[PASS]' if passed else '[BLOCKED]'}: {confirmed}/{total}",
|
|
563
598
|
}
|
|
564
599
|
|
|
600
|
+
# M1.5: Context enrichment — compliant agents get rich context for free
|
|
601
|
+
if passed:
|
|
602
|
+
enrichment = {}
|
|
603
|
+
for dim_key in ["D", "R", "A", "F", "T"]:
|
|
604
|
+
dim_fields = dims.get(dim_key, {})
|
|
605
|
+
if isinstance(dim_fields, dict) and dim_fields.get("_screened"):
|
|
606
|
+
enrichment[dim_key] = {"_screened": True, "_reason": dim_fields.get("_reason", "N/A")}
|
|
607
|
+
continue
|
|
608
|
+
dim_data = {}
|
|
609
|
+
for fk, info in dim_fields.items():
|
|
610
|
+
if fk.startswith("_") or not isinstance(info, dict):
|
|
611
|
+
continue
|
|
612
|
+
dim_data[fk] = {
|
|
613
|
+
"value": info.get("extracted", ""),
|
|
614
|
+
"question": info.get("question", ""),
|
|
615
|
+
"status": info.get("status", "UNKNOWN"),
|
|
616
|
+
}
|
|
617
|
+
enrichment[dim_key] = dim_data
|
|
618
|
+
result["context_enrichment"] = enrichment
|
|
619
|
+
result["tier"] = session.get("tier", "UNKNOWN")
|
|
620
|
+
|
|
621
|
+
return result
|
|
622
|
+
|
|
565
623
|
|
|
566
624
|
# ── Field Operations ──────────────────────────────────────
|
|
567
625
|
|
|
568
626
|
|
|
569
627
|
def confirm_field(session_id: str, field_key: str, value: str) -> dict:
|
|
570
628
|
"""Confirm a DRAFT field with a human-provided answer."""
|
|
629
|
+
# M1.3: Closed session guard
|
|
630
|
+
closed = _check_open(session_id)
|
|
631
|
+
if closed:
|
|
632
|
+
return closed
|
|
633
|
+
|
|
571
634
|
session = storage.get_session(session_id)
|
|
572
635
|
if not session:
|
|
573
636
|
return {"error": "Session not found"}
|
|
@@ -611,6 +674,11 @@ def confirm_field(session_id: str, field_key: str, value: str) -> dict:
|
|
|
611
674
|
|
|
612
675
|
def unscreen_dimension(session_id: str, dimension_key: str) -> dict:
|
|
613
676
|
"""Reverse screening on a dimension incorrectly marked N/A."""
|
|
677
|
+
# M1.3: Closed session guard
|
|
678
|
+
closed = _check_open(session_id)
|
|
679
|
+
if closed:
|
|
680
|
+
return closed
|
|
681
|
+
|
|
614
682
|
session = storage.get_session(session_id)
|
|
615
683
|
if not session:
|
|
616
684
|
return {"error": "Session not found"}
|
|
@@ -637,6 +705,11 @@ def unscreen_dimension(session_id: str, dimension_key: str) -> dict:
|
|
|
637
705
|
|
|
638
706
|
def add_assumption(session_id: str, claim: str, source: str = "manual", falsifier: str = "") -> dict:
|
|
639
707
|
"""Add a manually authored assumption."""
|
|
708
|
+
# M1.3: Closed session guard
|
|
709
|
+
closed = _check_open(session_id)
|
|
710
|
+
if closed:
|
|
711
|
+
return closed
|
|
712
|
+
|
|
640
713
|
session = storage.get_session(session_id)
|
|
641
714
|
if not session:
|
|
642
715
|
return {"error": "Session not found"}
|
|
@@ -658,6 +731,11 @@ def add_assumption(session_id: str, claim: str, source: str = "manual", falsifie
|
|
|
658
731
|
|
|
659
732
|
def override_gate(session_id: str, reason: str) -> dict:
|
|
660
733
|
"""Override a blocked gate with logged reason (authorized override)."""
|
|
734
|
+
# M1.3: Closed session guard
|
|
735
|
+
closed = _check_open(session_id)
|
|
736
|
+
if closed:
|
|
737
|
+
return closed
|
|
738
|
+
|
|
661
739
|
session = storage.get_session(session_id)
|
|
662
740
|
if not session:
|
|
663
741
|
return {"error": "Session not found"}
|
|
@@ -677,6 +755,11 @@ def override_gate(session_id: str, reason: str) -> dict:
|
|
|
677
755
|
|
|
678
756
|
def verify_assumption(session_id: str, index: int, verified: bool, note: str = "") -> dict:
|
|
679
757
|
"""Verify or reject an assumption."""
|
|
758
|
+
# M1.3: Closed session guard
|
|
759
|
+
closed = _check_open(session_id)
|
|
760
|
+
if closed:
|
|
761
|
+
return closed
|
|
762
|
+
|
|
680
763
|
session = storage.get_session(session_id)
|
|
681
764
|
if not session:
|
|
682
765
|
return {"error": "Session not found"}
|
|
@@ -698,6 +781,11 @@ def verify_assumption(session_id: str, index: int, verified: bool, note: str = "
|
|
|
698
781
|
|
|
699
782
|
def elicitation_review(session_id: str) -> dict:
|
|
700
783
|
"""Self-assessment of elicitation quality."""
|
|
784
|
+
# M1.3: Closed session guard
|
|
785
|
+
closed = _check_open(session_id)
|
|
786
|
+
if closed:
|
|
787
|
+
return closed
|
|
788
|
+
|
|
701
789
|
session = storage.get_session(session_id)
|
|
702
790
|
if not session:
|
|
703
791
|
return {"error": "Session not found"}
|
|
@@ -7,6 +7,9 @@ from datetime import datetime, timezone
|
|
|
7
7
|
|
|
8
8
|
from draft_protocol.config import DB_PATH
|
|
9
9
|
|
|
10
|
+
# M1.4: Valid tier enum — reject anything not in this set
|
|
11
|
+
VALID_TIERS = {"CASUAL", "STANDARD", "CONSEQUENTIAL"}
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
def get_db() -> sqlite3.Connection:
|
|
12
15
|
conn = sqlite3.connect(str(DB_PATH))
|
|
@@ -57,6 +60,9 @@ def _now() -> str:
|
|
|
57
60
|
|
|
58
61
|
def create_session(tier: str, intent: str) -> str:
|
|
59
62
|
"""Create a new DRAFT session. Returns session_id."""
|
|
63
|
+
# M1.4: Validate tier enum
|
|
64
|
+
if tier not in VALID_TIERS:
|
|
65
|
+
raise ValueError(f"Invalid tier '{tier}'. Must be one of: {', '.join(sorted(VALID_TIERS))}")
|
|
60
66
|
sid = str(uuid.uuid4())[:12]
|
|
61
67
|
conn = get_db()
|
|
62
68
|
now = _now()
|
|
@@ -83,6 +89,16 @@ def get_session(session_id: str) -> dict | None:
|
|
|
83
89
|
return d
|
|
84
90
|
|
|
85
91
|
|
|
92
|
+
def is_session_closed(session_id: str) -> bool:
|
|
93
|
+
"""Check if a session is closed. M1.3: Closed session guard."""
|
|
94
|
+
conn = get_db()
|
|
95
|
+
row = conn.execute("SELECT closed_at FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
96
|
+
conn.close()
|
|
97
|
+
if not row:
|
|
98
|
+
return True # Nonexistent sessions treated as closed
|
|
99
|
+
return row["closed_at"] is not None
|
|
100
|
+
|
|
101
|
+
|
|
86
102
|
def get_active_session() -> dict | None:
|
|
87
103
|
"""Get the most recent unclosed session."""
|
|
88
104
|
conn = get_db()
|
|
@@ -98,6 +114,9 @@ def get_active_session() -> dict | None:
|
|
|
98
114
|
|
|
99
115
|
def update_session(session_id: str, **kwargs):
|
|
100
116
|
"""Update session fields. JSON fields auto-serialized."""
|
|
117
|
+
# M1.4: Validate tier if being updated
|
|
118
|
+
if "tier" in kwargs and kwargs["tier"] not in VALID_TIERS:
|
|
119
|
+
raise ValueError(f"Invalid tier '{kwargs['tier']}'. Must be one of: {', '.join(sorted(VALID_TIERS))}")
|
|
101
120
|
conn = get_db()
|
|
102
121
|
sets = ["updated_at = ?"]
|
|
103
122
|
vals = [_now()]
|
|
@@ -21,10 +21,12 @@ from draft_protocol.engine import ( # noqa: E402
|
|
|
21
21
|
verify_assumption,
|
|
22
22
|
)
|
|
23
23
|
from draft_protocol.storage import ( # noqa: E402
|
|
24
|
+
VALID_TIERS,
|
|
24
25
|
close_session,
|
|
25
26
|
create_session,
|
|
26
27
|
get_active_session,
|
|
27
28
|
get_session,
|
|
29
|
+
is_session_closed,
|
|
28
30
|
log_audit,
|
|
29
31
|
)
|
|
30
32
|
|
|
@@ -377,3 +379,199 @@ class TestProviderConfig:
|
|
|
377
379
|
assert "ollama" in _EMBED_PROVIDERS
|
|
378
380
|
assert "openai" in _EMBED_PROVIDERS
|
|
379
381
|
assert "anthropic" in _EMBED_PROVIDERS
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ── M1.3: Closed Session Guards ───────────────────────────
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class TestClosedSessionGuard:
|
|
388
|
+
"""M1.3: All operations on closed sessions must return error."""
|
|
389
|
+
|
|
390
|
+
def _make_closed_session(self):
|
|
391
|
+
sid = create_session("STANDARD", "test")
|
|
392
|
+
map_dimensions(sid, "Build a tool for processing")
|
|
393
|
+
close_session(sid)
|
|
394
|
+
return sid
|
|
395
|
+
|
|
396
|
+
def test_is_session_closed(self):
|
|
397
|
+
sid = create_session("STANDARD", "test")
|
|
398
|
+
assert not is_session_closed(sid)
|
|
399
|
+
close_session(sid)
|
|
400
|
+
assert is_session_closed(sid)
|
|
401
|
+
|
|
402
|
+
def test_nonexistent_treated_as_closed(self):
|
|
403
|
+
assert is_session_closed("nonexistent_id_xyz")
|
|
404
|
+
|
|
405
|
+
def test_map_dimensions_blocked(self):
|
|
406
|
+
sid = self._make_closed_session()
|
|
407
|
+
result = map_dimensions(sid, "some context")
|
|
408
|
+
assert "error" in result
|
|
409
|
+
assert "closed" in result["error"].lower()
|
|
410
|
+
|
|
411
|
+
def test_generate_elicitation_blocked(self):
|
|
412
|
+
sid = self._make_closed_session()
|
|
413
|
+
result = generate_elicitation(sid)
|
|
414
|
+
assert len(result) == 1
|
|
415
|
+
assert "error" in result[0]
|
|
416
|
+
assert "closed" in result[0]["error"].lower()
|
|
417
|
+
|
|
418
|
+
def test_confirm_field_blocked(self):
|
|
419
|
+
sid = self._make_closed_session()
|
|
420
|
+
result = confirm_field(sid, "D1", "Some value here")
|
|
421
|
+
assert "error" in result
|
|
422
|
+
assert "closed" in result["error"].lower()
|
|
423
|
+
|
|
424
|
+
def test_check_gate_blocked(self):
|
|
425
|
+
sid = self._make_closed_session()
|
|
426
|
+
result = check_gate(sid)
|
|
427
|
+
assert not result["passed"]
|
|
428
|
+
assert any("closed" in b.lower() for b in result["blockers"])
|
|
429
|
+
|
|
430
|
+
def test_generate_assumptions_blocked(self):
|
|
431
|
+
sid = self._make_closed_session()
|
|
432
|
+
result = generate_assumptions(sid)
|
|
433
|
+
assert len(result) == 1
|
|
434
|
+
assert "error" in result[0]
|
|
435
|
+
|
|
436
|
+
def test_verify_assumption_blocked(self):
|
|
437
|
+
sid = self._make_closed_session()
|
|
438
|
+
result = verify_assumption(sid, 0, True)
|
|
439
|
+
assert "error" in result
|
|
440
|
+
assert "closed" in result["error"].lower()
|
|
441
|
+
|
|
442
|
+
def test_add_assumption_blocked(self):
|
|
443
|
+
sid = self._make_closed_session()
|
|
444
|
+
result = add_assumption(sid, "Test claim")
|
|
445
|
+
assert "error" in result
|
|
446
|
+
assert "closed" in result["error"].lower()
|
|
447
|
+
|
|
448
|
+
def test_override_gate_blocked(self):
|
|
449
|
+
sid = self._make_closed_session()
|
|
450
|
+
result = override_gate(sid, "Override reason")
|
|
451
|
+
assert "error" in result
|
|
452
|
+
assert "closed" in result["error"].lower()
|
|
453
|
+
|
|
454
|
+
def test_unscreen_dimension_blocked(self):
|
|
455
|
+
sid = self._make_closed_session()
|
|
456
|
+
result = unscreen_dimension(sid, "R")
|
|
457
|
+
assert "error" in result
|
|
458
|
+
assert "closed" in result["error"].lower()
|
|
459
|
+
|
|
460
|
+
def test_elicitation_review_blocked(self):
|
|
461
|
+
sid = self._make_closed_session()
|
|
462
|
+
result = elicitation_review(sid)
|
|
463
|
+
assert "error" in result
|
|
464
|
+
assert "closed" in result["error"].lower()
|
|
465
|
+
|
|
466
|
+
def test_error_message_includes_session_id(self):
|
|
467
|
+
sid = self._make_closed_session()
|
|
468
|
+
result = confirm_field(sid, "D1", "Some value")
|
|
469
|
+
assert sid in result["error"]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ── M1.4: Tier Enum Validation ────────────────────────────
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class TestTierEnumValidation:
|
|
476
|
+
"""M1.4: Invalid tier strings must be rejected."""
|
|
477
|
+
|
|
478
|
+
def test_valid_tiers_constant_exists(self):
|
|
479
|
+
assert {"CASUAL", "STANDARD", "CONSEQUENTIAL"} == VALID_TIERS
|
|
480
|
+
|
|
481
|
+
def test_create_session_valid_tiers(self):
|
|
482
|
+
for tier in VALID_TIERS:
|
|
483
|
+
sid = create_session(tier, "test")
|
|
484
|
+
session = get_session(sid)
|
|
485
|
+
assert session["tier"] == tier
|
|
486
|
+
|
|
487
|
+
def test_create_session_invalid_tier_rejected(self):
|
|
488
|
+
import pytest
|
|
489
|
+
with pytest.raises(ValueError, match="Invalid tier"):
|
|
490
|
+
create_session("SUPER_HIGH", "test")
|
|
491
|
+
|
|
492
|
+
def test_create_session_lowercase_rejected(self):
|
|
493
|
+
import pytest
|
|
494
|
+
with pytest.raises(ValueError, match="Invalid tier"):
|
|
495
|
+
create_session("casual", "test")
|
|
496
|
+
|
|
497
|
+
def test_create_session_empty_tier_rejected(self):
|
|
498
|
+
import pytest
|
|
499
|
+
with pytest.raises(ValueError, match="Invalid tier"):
|
|
500
|
+
create_session("", "test")
|
|
501
|
+
|
|
502
|
+
def test_update_session_invalid_tier_rejected(self):
|
|
503
|
+
import pytest
|
|
504
|
+
|
|
505
|
+
from draft_protocol.storage import update_session
|
|
506
|
+
sid = create_session("CASUAL", "test")
|
|
507
|
+
with pytest.raises(ValueError, match="Invalid tier"):
|
|
508
|
+
update_session(sid, tier="INVALID")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ── M1.5: Context Enrichment Return ──────────────────────
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class TestContextEnrichment:
|
|
515
|
+
"""M1.5: Gate PASS returns full dimensional mapping as structured context."""
|
|
516
|
+
|
|
517
|
+
def _make_passed_session(self):
|
|
518
|
+
sid = create_session("STANDARD", "test")
|
|
519
|
+
map_dimensions(sid, "Build a tool for processing data")
|
|
520
|
+
session = get_session(sid)
|
|
521
|
+
dims = session["dimensions"]
|
|
522
|
+
for _dk, fields in dims.items():
|
|
523
|
+
if isinstance(fields, dict) and fields.get("_screened"):
|
|
524
|
+
continue
|
|
525
|
+
for fk in fields:
|
|
526
|
+
if not fk.startswith("_"):
|
|
527
|
+
confirm_field(sid, fk, f"Confirmed value for {fk} with enough content")
|
|
528
|
+
return sid
|
|
529
|
+
|
|
530
|
+
def test_gate_pass_includes_enrichment(self):
|
|
531
|
+
sid = self._make_passed_session()
|
|
532
|
+
gate = check_gate(sid)
|
|
533
|
+
assert gate["passed"]
|
|
534
|
+
assert "context_enrichment" in gate
|
|
535
|
+
|
|
536
|
+
def test_enrichment_has_all_dimensions(self):
|
|
537
|
+
sid = self._make_passed_session()
|
|
538
|
+
gate = check_gate(sid)
|
|
539
|
+
enrichment = gate["context_enrichment"]
|
|
540
|
+
for dim_key in ["D", "R", "A", "F", "T"]:
|
|
541
|
+
assert dim_key in enrichment
|
|
542
|
+
|
|
543
|
+
def test_enrichment_fields_have_value_and_question(self):
|
|
544
|
+
sid = self._make_passed_session()
|
|
545
|
+
gate = check_gate(sid)
|
|
546
|
+
enrichment = gate["context_enrichment"]
|
|
547
|
+
for _dim_key, dim_data in enrichment.items():
|
|
548
|
+
if dim_data.get("_screened"):
|
|
549
|
+
continue
|
|
550
|
+
for fk, info in dim_data.items():
|
|
551
|
+
if fk.startswith("_"):
|
|
552
|
+
continue
|
|
553
|
+
assert "value" in info
|
|
554
|
+
assert "question" in info
|
|
555
|
+
assert "status" in info
|
|
556
|
+
|
|
557
|
+
def test_enrichment_includes_tier(self):
|
|
558
|
+
sid = self._make_passed_session()
|
|
559
|
+
gate = check_gate(sid)
|
|
560
|
+
assert "tier" in gate
|
|
561
|
+
assert gate["tier"] == "STANDARD"
|
|
562
|
+
|
|
563
|
+
def test_gate_fail_no_enrichment(self):
|
|
564
|
+
sid = create_session("STANDARD", "test")
|
|
565
|
+
map_dimensions(sid, "Build a tool")
|
|
566
|
+
gate = check_gate(sid)
|
|
567
|
+
assert not gate["passed"]
|
|
568
|
+
assert "context_enrichment" not in gate
|
|
569
|
+
|
|
570
|
+
def test_screened_dimensions_marked_in_enrichment(self):
|
|
571
|
+
sid = self._make_passed_session()
|
|
572
|
+
gate = check_gate(sid)
|
|
573
|
+
if gate["passed"]:
|
|
574
|
+
enrichment = gate["context_enrichment"]
|
|
575
|
+
for _dim_key, dim_data in enrichment.items():
|
|
576
|
+
if dim_data.get("_screened"):
|
|
577
|
+
assert "_reason" in dim_data
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|