codevoight 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.
- codevoight/__init__.py +8 -0
- codevoight/cli.py +219 -0
- codevoight/core/config.py +51 -0
- codevoight/core/diff.py +51 -0
- codevoight/core/models.py +82 -0
- codevoight/core/security.py +68 -0
- codevoight/db/repository.py +221 -0
- codevoight/db/schema.py +40 -0
- codevoight/generators/base.py +20 -0
- codevoight/generators/deterministic.py +216 -0
- codevoight/generators/llm.py +33 -0
- codevoight/git/hook.py +113 -0
- codevoight/git/runner.py +57 -0
- codevoight/server.py +209 -0
- codevoight-0.1.0.dist-info/METADATA +191 -0
- codevoight-0.1.0.dist-info/RECORD +20 -0
- codevoight-0.1.0.dist-info/WHEEL +5 -0
- codevoight-0.1.0.dist-info/entry_points.txt +2 -0
- codevoight-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevoight-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Iterator, List, Optional
|
|
9
|
+
|
|
10
|
+
from codevoight.core.models import (
|
|
11
|
+
QuizOption,
|
|
12
|
+
QuizQuestion,
|
|
13
|
+
SessionStatus,
|
|
14
|
+
StagedSession,
|
|
15
|
+
)
|
|
16
|
+
from codevoight.db.schema import init_db
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StateRepository:
|
|
20
|
+
def __init__(self, db_path: Path):
|
|
21
|
+
self.db_path = db_path
|
|
22
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self._ensure_init()
|
|
24
|
+
|
|
25
|
+
@contextmanager
|
|
26
|
+
def _get_connection(self) -> Iterator[sqlite3.Connection]:
|
|
27
|
+
conn = sqlite3.connect(str(self.db_path), timeout=10.0)
|
|
28
|
+
conn.row_factory = sqlite3.Row
|
|
29
|
+
try:
|
|
30
|
+
with conn:
|
|
31
|
+
yield conn
|
|
32
|
+
finally:
|
|
33
|
+
conn.close()
|
|
34
|
+
|
|
35
|
+
def _ensure_init(self) -> None:
|
|
36
|
+
with self._get_connection() as conn:
|
|
37
|
+
init_db(conn)
|
|
38
|
+
|
|
39
|
+
def create_session(self, session: StagedSession, questions: List[QuizQuestion]) -> None:
|
|
40
|
+
with self._get_connection() as conn:
|
|
41
|
+
cursor = conn.cursor()
|
|
42
|
+
cursor.execute(
|
|
43
|
+
"""
|
|
44
|
+
INSERT INTO sessions (
|
|
45
|
+
session_id, diff_text, diff_hash, description, status,
|
|
46
|
+
created_at, expires_at, capability_token
|
|
47
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
48
|
+
""",
|
|
49
|
+
(
|
|
50
|
+
session.session_id,
|
|
51
|
+
session.diff_text,
|
|
52
|
+
session.diff_hash,
|
|
53
|
+
session.description,
|
|
54
|
+
session.status.value,
|
|
55
|
+
session.created_at,
|
|
56
|
+
session.expires_at,
|
|
57
|
+
session.capability_token,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
for q in questions:
|
|
62
|
+
options_data = [opt.model_dump() for opt in q.options]
|
|
63
|
+
cursor.execute(
|
|
64
|
+
"""
|
|
65
|
+
INSERT INTO questions (
|
|
66
|
+
id, session_id, prompt, options_json, correct_option_id, explanation
|
|
67
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
68
|
+
""",
|
|
69
|
+
(
|
|
70
|
+
q.id,
|
|
71
|
+
session.session_id,
|
|
72
|
+
q.prompt,
|
|
73
|
+
json.dumps(options_data),
|
|
74
|
+
q.correct_option_id,
|
|
75
|
+
q.explanation,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
conn.commit()
|
|
79
|
+
|
|
80
|
+
def get_session(self, session_id: str) -> Optional[StagedSession]:
|
|
81
|
+
with self._get_connection() as conn:
|
|
82
|
+
cursor = conn.cursor()
|
|
83
|
+
cursor.execute(
|
|
84
|
+
"SELECT * FROM sessions WHERE session_id = ?",
|
|
85
|
+
(session_id,),
|
|
86
|
+
)
|
|
87
|
+
row = cursor.fetchone()
|
|
88
|
+
if not row:
|
|
89
|
+
return None
|
|
90
|
+
return StagedSession(
|
|
91
|
+
session_id=row["session_id"],
|
|
92
|
+
diff_text=row["diff_text"],
|
|
93
|
+
diff_hash=row["diff_hash"],
|
|
94
|
+
description=row["description"],
|
|
95
|
+
status=SessionStatus(row["status"]),
|
|
96
|
+
created_at=row["created_at"],
|
|
97
|
+
expires_at=row["expires_at"],
|
|
98
|
+
capability_token=row["capability_token"],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def get_latest_pending_session(self) -> Optional[StagedSession]:
|
|
102
|
+
with self._get_connection() as conn:
|
|
103
|
+
cursor = conn.cursor()
|
|
104
|
+
cursor.execute(
|
|
105
|
+
"""
|
|
106
|
+
SELECT * FROM sessions
|
|
107
|
+
WHERE status = ? AND expires_at > ?
|
|
108
|
+
ORDER BY created_at DESC LIMIT 1
|
|
109
|
+
""",
|
|
110
|
+
(SessionStatus.PENDING_QUIZ.value, time.time()),
|
|
111
|
+
)
|
|
112
|
+
row = cursor.fetchone()
|
|
113
|
+
if not row:
|
|
114
|
+
return None
|
|
115
|
+
return StagedSession(
|
|
116
|
+
session_id=row["session_id"],
|
|
117
|
+
diff_text=row["diff_text"],
|
|
118
|
+
diff_hash=row["diff_hash"],
|
|
119
|
+
description=row["description"],
|
|
120
|
+
status=SessionStatus(row["status"]),
|
|
121
|
+
created_at=row["created_at"],
|
|
122
|
+
expires_at=row["expires_at"],
|
|
123
|
+
capability_token=row["capability_token"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get_questions_for_session(self, session_id: str) -> List[QuizQuestion]:
|
|
127
|
+
with self._get_connection() as conn:
|
|
128
|
+
cursor = conn.cursor()
|
|
129
|
+
cursor.execute(
|
|
130
|
+
"SELECT * FROM questions WHERE session_id = ?",
|
|
131
|
+
(session_id,),
|
|
132
|
+
)
|
|
133
|
+
rows = cursor.fetchall()
|
|
134
|
+
questions = []
|
|
135
|
+
for r in rows:
|
|
136
|
+
opts = [QuizOption(**opt) for opt in json.loads(r["options_json"])]
|
|
137
|
+
questions.append(
|
|
138
|
+
QuizQuestion(
|
|
139
|
+
id=r["id"],
|
|
140
|
+
prompt=r["prompt"],
|
|
141
|
+
options=opts,
|
|
142
|
+
correct_option_id=r["correct_option_id"],
|
|
143
|
+
explanation=r["explanation"],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return questions
|
|
147
|
+
|
|
148
|
+
def update_session_status(
|
|
149
|
+
self, session_id: str, status: SessionStatus, token: Optional[str] = None
|
|
150
|
+
) -> None:
|
|
151
|
+
with self._get_connection() as conn:
|
|
152
|
+
cursor = conn.cursor()
|
|
153
|
+
if token is not None:
|
|
154
|
+
cursor.execute(
|
|
155
|
+
"UPDATE sessions SET status = ?, capability_token = ? WHERE session_id = ?",
|
|
156
|
+
(status.value, token, session_id),
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
cursor.execute(
|
|
160
|
+
"UPDATE sessions SET status = ? WHERE session_id = ?",
|
|
161
|
+
(status.value, session_id),
|
|
162
|
+
)
|
|
163
|
+
conn.commit()
|
|
164
|
+
|
|
165
|
+
def find_valid_approved_session(self, diff_hash: str) -> Optional[StagedSession]:
|
|
166
|
+
now = time.time()
|
|
167
|
+
with self._get_connection() as conn:
|
|
168
|
+
cursor = conn.cursor()
|
|
169
|
+
cursor.execute(
|
|
170
|
+
"""
|
|
171
|
+
SELECT * FROM sessions
|
|
172
|
+
WHERE diff_hash = ? AND status = ? AND expires_at > ? AND capability_token IS NOT NULL
|
|
173
|
+
ORDER BY created_at DESC LIMIT 1
|
|
174
|
+
""",
|
|
175
|
+
(diff_hash, SessionStatus.APPROVED.value, now),
|
|
176
|
+
)
|
|
177
|
+
row = cursor.fetchone()
|
|
178
|
+
if not row:
|
|
179
|
+
return None
|
|
180
|
+
return StagedSession(
|
|
181
|
+
session_id=row["session_id"],
|
|
182
|
+
diff_text=row["diff_text"],
|
|
183
|
+
diff_hash=row["diff_hash"],
|
|
184
|
+
description=row["description"],
|
|
185
|
+
status=SessionStatus(row["status"]),
|
|
186
|
+
created_at=row["created_at"],
|
|
187
|
+
expires_at=row["expires_at"],
|
|
188
|
+
capability_token=row["capability_token"],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def consume_session_token(self, session_id: str) -> bool:
|
|
192
|
+
with self._get_connection() as conn:
|
|
193
|
+
cursor = conn.cursor()
|
|
194
|
+
cursor.execute(
|
|
195
|
+
"UPDATE sessions SET status = ? WHERE session_id = ? AND status = ?",
|
|
196
|
+
(SessionStatus.CONSUMED.value, session_id, SessionStatus.APPROVED.value),
|
|
197
|
+
)
|
|
198
|
+
conn.commit()
|
|
199
|
+
return cursor.rowcount > 0
|
|
200
|
+
|
|
201
|
+
def list_sessions(self, limit: int = 20) -> List[StagedSession]:
|
|
202
|
+
with self._get_connection() as conn:
|
|
203
|
+
cursor = conn.cursor()
|
|
204
|
+
cursor.execute(
|
|
205
|
+
"SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?",
|
|
206
|
+
(limit,),
|
|
207
|
+
)
|
|
208
|
+
rows = cursor.fetchall()
|
|
209
|
+
return [
|
|
210
|
+
StagedSession(
|
|
211
|
+
session_id=r["session_id"],
|
|
212
|
+
diff_text=r["diff_text"],
|
|
213
|
+
diff_hash=r["diff_hash"],
|
|
214
|
+
description=r["description"],
|
|
215
|
+
status=SessionStatus(r["status"]),
|
|
216
|
+
created_at=r["created_at"],
|
|
217
|
+
expires_at=r["expires_at"],
|
|
218
|
+
capability_token=r["capability_token"],
|
|
219
|
+
)
|
|
220
|
+
for r in rows
|
|
221
|
+
]
|
codevoight/db/schema.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
INIT_SCHEMA_SQL = """
|
|
6
|
+
PRAGMA journal_mode=WAL;
|
|
7
|
+
PRAGMA busy_timeout=5000;
|
|
8
|
+
PRAGMA foreign_keys=ON;
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
11
|
+
session_id TEXT PRIMARY KEY,
|
|
12
|
+
diff_text TEXT NOT NULL,
|
|
13
|
+
diff_hash TEXT NOT NULL,
|
|
14
|
+
description TEXT DEFAULT '',
|
|
15
|
+
status TEXT NOT NULL,
|
|
16
|
+
created_at REAL NOT NULL,
|
|
17
|
+
expires_at REAL NOT NULL,
|
|
18
|
+
capability_token TEXT DEFAULT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS questions (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
session_id TEXT NOT NULL,
|
|
24
|
+
prompt TEXT NOT NULL,
|
|
25
|
+
options_json TEXT NOT NULL,
|
|
26
|
+
correct_option_id TEXT NOT NULL,
|
|
27
|
+
explanation TEXT DEFAULT '',
|
|
28
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_diff_hash ON sessions(diff_hash);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_questions_session ON questions(session_id);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def init_db(conn: sqlite3.Connection) -> None:
|
|
38
|
+
"""Initialize database tables, pragmas, and indexes."""
|
|
39
|
+
conn.executescript(INIT_SCHEMA_SQL)
|
|
40
|
+
conn.commit()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from codevoight.core.models import QuizQuestion, QuizResult, QuizSubmission
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseQuizGenerator(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
|
|
12
|
+
"""Generate comprehension questions based on the diff and optional description."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BaseQuizEvaluator(ABC):
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def evaluate(self, submission: QuizSubmission, questions: List[QuizQuestion]) -> QuizResult:
|
|
19
|
+
"""Evaluate developer answers and determine pass/fail with feedback."""
|
|
20
|
+
pass
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import List, Set
|
|
6
|
+
from codevoight.core.models import (
|
|
7
|
+
QuizOption,
|
|
8
|
+
QuizQuestion,
|
|
9
|
+
QuizResult,
|
|
10
|
+
QuizSubmission,
|
|
11
|
+
)
|
|
12
|
+
from codevoight.generators.base import BaseQuizEvaluator, BaseQuizGenerator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DeterministicQuizGenerator(BaseQuizGenerator):
|
|
16
|
+
"""Zero-dependency, offline deterministic quiz generator based on AST/diff heuristics."""
|
|
17
|
+
|
|
18
|
+
def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
|
|
19
|
+
questions: List[QuizQuestion] = []
|
|
20
|
+
files = self._extract_filenames(diff_text)
|
|
21
|
+
added_lines, removed_lines = self._extract_added_removed(diff_text)
|
|
22
|
+
symbols = self._extract_symbols(diff_text)
|
|
23
|
+
|
|
24
|
+
# Question 1: File targeting
|
|
25
|
+
if files:
|
|
26
|
+
primary_file = files[0]
|
|
27
|
+
options = [
|
|
28
|
+
QuizOption(id="A", text=f"Modifies or introduces `{primary_file}`"),
|
|
29
|
+
QuizOption(id="B", text=f"Deletes all tests in `tests/test_legacy.py`"),
|
|
30
|
+
QuizOption(id="C", text=f"Replaces deployment manifests in `.k8s/`"),
|
|
31
|
+
QuizOption(id="D", text=f"Only updates documentation in `docs/architecture.md`"),
|
|
32
|
+
]
|
|
33
|
+
questions.append(
|
|
34
|
+
QuizQuestion(
|
|
35
|
+
id=str(uuid.uuid4())[:8],
|
|
36
|
+
prompt=f"Which target file is modified in this staged diff?",
|
|
37
|
+
options=options,
|
|
38
|
+
correct_option_id="A",
|
|
39
|
+
explanation=f"The diff explicitly modifies `{primary_file}`.",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Question 2: Symbol / Function change
|
|
44
|
+
if symbols:
|
|
45
|
+
primary_symbol = symbols[0]
|
|
46
|
+
options = [
|
|
47
|
+
QuizOption(id="A", text=f"Introduces or modifies `{primary_symbol}`"),
|
|
48
|
+
QuizOption(id="B", text=f"Deprecated `{primary_symbol}_legacy`"),
|
|
49
|
+
QuizOption(id="C", text=f"Removes `{primary_symbol}` completely"),
|
|
50
|
+
QuizOption(id="D", text="Does not touch any function or class definitions"),
|
|
51
|
+
]
|
|
52
|
+
questions.append(
|
|
53
|
+
QuizQuestion(
|
|
54
|
+
id=str(uuid.uuid4())[:8],
|
|
55
|
+
prompt="Which symbol or function definition is directly affected by this diff?",
|
|
56
|
+
options=options,
|
|
57
|
+
correct_option_id="A",
|
|
58
|
+
explanation=f"The diff contains changes to `{primary_symbol}`.",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
# Fallback based on added content length / keywords
|
|
63
|
+
add_count = len(added_lines)
|
|
64
|
+
rem_count = len(removed_lines)
|
|
65
|
+
correct_summary = f"Adds ~{add_count} lines and removes ~{rem_count} lines"
|
|
66
|
+
options = [
|
|
67
|
+
QuizOption(id="A", text=correct_summary),
|
|
68
|
+
QuizOption(id="B", text="Deletes the entire repository database"),
|
|
69
|
+
QuizOption(id="C", text="Performs a full rollback to commit HEAD~10"),
|
|
70
|
+
QuizOption(id="D", text="Changes only binary asset files"),
|
|
71
|
+
]
|
|
72
|
+
questions.append(
|
|
73
|
+
QuizQuestion(
|
|
74
|
+
id=str(uuid.uuid4())[:8],
|
|
75
|
+
prompt="What is the net line modification profile of this change?",
|
|
76
|
+
options=options,
|
|
77
|
+
correct_option_id="A",
|
|
78
|
+
explanation=f"The diff consists of {add_count} additions and {rem_count} deletions.",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Question 3: Semantic intent or condition
|
|
83
|
+
has_error_handling = any(
|
|
84
|
+
re.search(r"\b(raise|try|except|throw|catch|error|Err)\b", line, re.I)
|
|
85
|
+
for line in added_lines
|
|
86
|
+
)
|
|
87
|
+
has_imports = any(
|
|
88
|
+
re.search(r"^\s*(import|from|require|include)\b", line)
|
|
89
|
+
for line in added_lines
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if has_error_handling:
|
|
93
|
+
options = [
|
|
94
|
+
QuizOption(id="A", text="Adds error handling, exception guards, or validation"),
|
|
95
|
+
QuizOption(id="B", text="Disables security assertions across all endpoints"),
|
|
96
|
+
QuizOption(id="C", text="Swallows exceptions with silent pass statements"),
|
|
97
|
+
QuizOption(id="D", text="Removes runtime input validation"),
|
|
98
|
+
]
|
|
99
|
+
questions.append(
|
|
100
|
+
QuizQuestion(
|
|
101
|
+
id=str(uuid.uuid4())[:8],
|
|
102
|
+
prompt="What validation or error handling behavior is added in this diff?",
|
|
103
|
+
options=options,
|
|
104
|
+
correct_option_id="A",
|
|
105
|
+
explanation="The diff introduces explicit exception handling or validation guards.",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
elif has_imports:
|
|
109
|
+
options = [
|
|
110
|
+
QuizOption(id="A", text="Imports or imports from additional modules/libraries"),
|
|
111
|
+
QuizOption(id="B", text="Uninstalls all third-party dependencies"),
|
|
112
|
+
QuizOption(id="C", text="Converts Python code to C++ bindings"),
|
|
113
|
+
QuizOption(id="D", text="Disables external module resolution"),
|
|
114
|
+
]
|
|
115
|
+
questions.append(
|
|
116
|
+
QuizQuestion(
|
|
117
|
+
id=str(uuid.uuid4())[:8],
|
|
118
|
+
prompt="What dependency or module change is present in this diff?",
|
|
119
|
+
options=options,
|
|
120
|
+
correct_option_id="A",
|
|
121
|
+
explanation="New import statements are introduced in the staged change.",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
options = [
|
|
126
|
+
QuizOption(id="A", text=f"Implements: {description or 'staged logic update'}"),
|
|
127
|
+
QuizOption(id="B", text="Reverts all recent git commits"),
|
|
128
|
+
QuizOption(id="C", text="Formats code with no functional edits"),
|
|
129
|
+
QuizOption(id="D", text="Increments package version number only"),
|
|
130
|
+
]
|
|
131
|
+
questions.append(
|
|
132
|
+
QuizQuestion(
|
|
133
|
+
id=str(uuid.uuid4())[:8],
|
|
134
|
+
prompt="What is the primary stated intent of this change?",
|
|
135
|
+
options=options,
|
|
136
|
+
correct_option_id="A",
|
|
137
|
+
explanation="Reflects the staged change intent.",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return questions
|
|
142
|
+
|
|
143
|
+
def _extract_filenames(self, diff_text: str) -> List[str]:
|
|
144
|
+
files: List[str] = []
|
|
145
|
+
for line in diff_text.splitlines():
|
|
146
|
+
if line.startswith("+++ b/"):
|
|
147
|
+
files.append(line[6:].strip())
|
|
148
|
+
elif line.startswith("--- a/") and line[6:].strip() not in files:
|
|
149
|
+
f = line[6:].strip()
|
|
150
|
+
if f != "/dev/null":
|
|
151
|
+
files.append(f)
|
|
152
|
+
return list(dict.fromkeys(files))
|
|
153
|
+
|
|
154
|
+
def _extract_added_removed(self, diff_text: str) -> tuple[List[str], List[str]]:
|
|
155
|
+
added = []
|
|
156
|
+
removed = []
|
|
157
|
+
for line in diff_text.splitlines():
|
|
158
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
159
|
+
added.append(line[1:].strip())
|
|
160
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
161
|
+
removed.append(line[1:].strip())
|
|
162
|
+
return added, removed
|
|
163
|
+
|
|
164
|
+
def _extract_symbols(self, diff_text: str) -> List[str]:
|
|
165
|
+
symbols: Set[str] = set()
|
|
166
|
+
pattern = re.compile(r"^\+\s*(?:async\s+)?(?:def|class|function|const|let|var)\s+([a-zA-Z0-9_]+)")
|
|
167
|
+
for line in diff_text.splitlines():
|
|
168
|
+
match = pattern.search(line)
|
|
169
|
+
if match:
|
|
170
|
+
symbols.add(match.group(1))
|
|
171
|
+
return sorted(list(symbols))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class DeterministicQuizEvaluator(BaseQuizEvaluator):
|
|
175
|
+
"""Deterministic grading engine enforcing 100% threshold by default."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, passing_score: float = 1.0):
|
|
178
|
+
self.passing_score = passing_score
|
|
179
|
+
|
|
180
|
+
def evaluate(self, submission: QuizSubmission, questions: List[QuizQuestion]) -> QuizResult:
|
|
181
|
+
if not questions:
|
|
182
|
+
return QuizResult(
|
|
183
|
+
session_id=submission.session_id,
|
|
184
|
+
passed=True,
|
|
185
|
+
score=1.0,
|
|
186
|
+
total=0,
|
|
187
|
+
correct=0,
|
|
188
|
+
feedback=["No questions required for this diff."],
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
total = len(questions)
|
|
192
|
+
correct = 0
|
|
193
|
+
feedback: List[str] = []
|
|
194
|
+
|
|
195
|
+
for q in questions:
|
|
196
|
+
chosen = submission.answers.get(q.id)
|
|
197
|
+
if chosen == q.correct_option_id:
|
|
198
|
+
correct += 1
|
|
199
|
+
feedback.append(f"Question '{q.prompt}': Correct!")
|
|
200
|
+
else:
|
|
201
|
+
feedback.append(
|
|
202
|
+
f"Question '{q.prompt}': Incorrect. You answered {chosen}, "
|
|
203
|
+
f"expected {q.correct_option_id}. {q.explanation}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
score = correct / total
|
|
207
|
+
passed = score >= self.passing_score
|
|
208
|
+
|
|
209
|
+
return QuizResult(
|
|
210
|
+
session_id=submission.session_id,
|
|
211
|
+
passed=passed,
|
|
212
|
+
score=score,
|
|
213
|
+
total=total,
|
|
214
|
+
correct=correct,
|
|
215
|
+
feedback=feedback,
|
|
216
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from codevoight.core.models import QuizQuestion
|
|
7
|
+
from codevoight.generators.base import BaseQuizGenerator
|
|
8
|
+
from codevoight.generators.deterministic import DeterministicQuizGenerator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LLMQuizGenerator(BaseQuizGenerator):
|
|
12
|
+
"""Pluggable LLM generator with automatic fallback to deterministic generator."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, fallback_generator: Optional[BaseQuizGenerator] = None):
|
|
15
|
+
self.fallback = fallback_generator or DeterministicQuizGenerator()
|
|
16
|
+
self.api_key = os.getenv("CODEVOIGHT_LLM_API_KEY") or os.getenv("QUIZGATE_LLM_API_KEY") or os.getenv("CODEGATE_LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
17
|
+
|
|
18
|
+
def generate_quiz(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
|
|
19
|
+
# If no LLM credentials provided or in offline/test mode, seamlessly use deterministic engine
|
|
20
|
+
if not self.api_key:
|
|
21
|
+
return self.fallback.generate_quiz(diff_text, description)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
# Pluggable LLM invocation hook
|
|
25
|
+
# If external network call fails or is not configured, fallback gracefully
|
|
26
|
+
return self._call_llm_api(diff_text, description)
|
|
27
|
+
except Exception:
|
|
28
|
+
return self.fallback.generate_quiz(diff_text, description)
|
|
29
|
+
|
|
30
|
+
def _call_llm_api(self, diff_text: str, description: str = "") -> List[QuizQuestion]:
|
|
31
|
+
# Placeholder for external LLM-as-a-judge provider integration
|
|
32
|
+
# In base install, falls back safely to deterministic rules
|
|
33
|
+
return self.fallback.generate_quiz(diff_text, description)
|
codevoight/git/hook.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from codevoight.core.diff import compute_diff_hash
|
|
9
|
+
from codevoight.core.models import CapabilityToken
|
|
10
|
+
from codevoight.core.security import get_or_create_secret, verify_token
|
|
11
|
+
from codevoight.db.repository import StateRepository
|
|
12
|
+
from codevoight.git.runner import GitRunner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
HOOK_SCRIPT = """#!/bin/sh
|
|
16
|
+
# CodeVoight Pre-Commit Verification Hook
|
|
17
|
+
# Enforces human comprehension quiz verification before git commit.
|
|
18
|
+
|
|
19
|
+
if [ -f ".venv/Scripts/python.exe" ]; then
|
|
20
|
+
PYTHON=".venv/Scripts/python.exe"
|
|
21
|
+
elif [ -f ".venv/bin/python" ]; then
|
|
22
|
+
PYTHON=".venv/bin/python"
|
|
23
|
+
elif command -v py >/dev/null 2>&1; then
|
|
24
|
+
PYTHON=py
|
|
25
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
26
|
+
PYTHON=python3
|
|
27
|
+
elif command -v python >/dev/null 2>&1; then
|
|
28
|
+
PYTHON=python
|
|
29
|
+
else
|
|
30
|
+
echo "[CODEVOIGHT] Error: Python runtime not found in PATH."
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
$PYTHON -m codevoight.cli verify-commit
|
|
35
|
+
EXIT_CODE=$?
|
|
36
|
+
|
|
37
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
38
|
+
echo ""
|
|
39
|
+
echo "[CODEVOIGHT] COMMIT BLOCKED: Staged changes require human comprehension approval."
|
|
40
|
+
echo "[CODEVOIGHT] To approve, run: codevoight quiz"
|
|
41
|
+
echo ""
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
exit 0
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_pre_commit_hook(repo_dir: Optional[Path] = None) -> Tuple[bool, str]:
|
|
50
|
+
"""Install the pre-commit hook into .git/hooks/pre-commit."""
|
|
51
|
+
runner = GitRunner(repo_dir)
|
|
52
|
+
root = runner.get_repo_root()
|
|
53
|
+
if not root:
|
|
54
|
+
return False, "Not inside a valid Git repository."
|
|
55
|
+
|
|
56
|
+
hooks_dir = root / ".git" / "hooks"
|
|
57
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
hook_file = hooks_dir / "pre-commit"
|
|
59
|
+
|
|
60
|
+
hook_file.write_text(HOOK_SCRIPT, encoding="utf-8", newline="\n")
|
|
61
|
+
|
|
62
|
+
# Set executable bit for unix/posix
|
|
63
|
+
try:
|
|
64
|
+
current_stat = os.stat(hook_file)
|
|
65
|
+
os.chmod(hook_file, current_stat.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return True, str(hook_file)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_staged_commit(workspace_root: Optional[Path] = None) -> Tuple[bool, str]:
|
|
73
|
+
"""Verify that current staged Git changes have a valid, unexpired approval token."""
|
|
74
|
+
runner = GitRunner(workspace_root)
|
|
75
|
+
root = runner.get_repo_root() or (workspace_root.resolve() if workspace_root else None)
|
|
76
|
+
if not root:
|
|
77
|
+
return False, "Not inside a git repository."
|
|
78
|
+
|
|
79
|
+
staged_diff = runner.get_staged_diff()
|
|
80
|
+
if not staged_diff.strip():
|
|
81
|
+
# Nothing staged or empty commit
|
|
82
|
+
return True, "No staged diff to verify."
|
|
83
|
+
|
|
84
|
+
diff_hash = compute_diff_hash(staged_diff)
|
|
85
|
+
|
|
86
|
+
codevoight_dir = root / ".codevoight"
|
|
87
|
+
if not codevoight_dir.exists():
|
|
88
|
+
if (root / ".quizgate").exists():
|
|
89
|
+
codevoight_dir = root / ".quizgate"
|
|
90
|
+
elif (root / ".codegate").exists():
|
|
91
|
+
codevoight_dir = root / ".codegate"
|
|
92
|
+
|
|
93
|
+
db_path = codevoight_dir / "state.db"
|
|
94
|
+
if not db_path.exists():
|
|
95
|
+
return False, "No CodeVoight state found. Run 'codevoight quiz' to approve staged changes."
|
|
96
|
+
|
|
97
|
+
repo = StateRepository(db_path)
|
|
98
|
+
session = repo.find_valid_approved_session(diff_hash)
|
|
99
|
+
if not session or not session.capability_token:
|
|
100
|
+
return False, f"Diff hash {diff_hash[:12]} has not been approved via quiz."
|
|
101
|
+
|
|
102
|
+
# Verify cryptographic signature
|
|
103
|
+
secret_key = get_or_create_secret(codevoight_dir)
|
|
104
|
+
try:
|
|
105
|
+
token = CapabilityToken.parse(session.capability_token)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return False, f"Invalid token format: {e}"
|
|
108
|
+
|
|
109
|
+
is_valid = verify_token(token, diff_hash, secret_key)
|
|
110
|
+
if not is_valid:
|
|
111
|
+
return False, "Token signature invalid or expired."
|
|
112
|
+
|
|
113
|
+
return True, f"Verified capability token for session {session.session_id}."
|