exegete 0.14.1a0.dev1__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.
- exegete/__init__.py +16 -0
- exegete/coder_comparison.py +208 -0
- exegete/cursors.py +218 -0
- exegete/database.py +12621 -0
- exegete/env_settings.py +174 -0
- exegete/memo_privacy.py +215 -0
- exegete/names.py +68 -0
- exegete/new_project.py +1027 -0
- exegete/preview_tokens.py +613 -0
- exegete/project_settings.py +1082 -0
- exegete/pseudonymise.py +2796 -0
- exegete/refi_export.py +645 -0
- exegete/server.py +18899 -0
- exegete/sessions.py +1153 -0
- exegete/state_folder.py +360 -0
- exegete/transition.py +1049 -0
- exegete-0.14.1a0.dev1.dist-info/METADATA +515 -0
- exegete-0.14.1a0.dev1.dist-info/RECORD +24 -0
- exegete-0.14.1a0.dev1.dist-info/WHEEL +5 -0
- exegete-0.14.1a0.dev1.dist-info/entry_points.txt +2 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/COPYING.LESSER +165 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/NOTICE +773 -0
- exegete-0.14.1a0.dev1.dist-info/licenses/legal/GPL-3.0.txt +674 -0
- exegete-0.14.1a0.dev1.dist-info/top_level.txt +1 -0
exegete/sessions.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
1
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
2
|
+
"""Session management for AI coding suggestions with disk persistence."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import List, Dict, Optional, Any
|
|
13
|
+
|
|
14
|
+
from .database import error_label
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# How a suggested code reads the passage (owner rulings 21 and 25, v0.14):
|
|
20
|
+
# a category, never a number, and the researcher's to change at review.
|
|
21
|
+
# Nothing sorts, filters or totals by it. `explicit`: the passage states
|
|
22
|
+
# what the code names; `interpretive`: the code rests on what the passage
|
|
23
|
+
# implies rather than on what it says (the reason then names the words it
|
|
24
|
+
# rests on). A suggestion recorded before v0.14 carried a 0-1
|
|
25
|
+
# "confidence" instead; it loads with no label (None), and the number is
|
|
26
|
+
# not kept. A session written while the label was called `support` loads
|
|
27
|
+
# its value as the reading.
|
|
28
|
+
READING_LABELS = {
|
|
29
|
+
"explicit": "the passage states what the code names",
|
|
30
|
+
"interpretive": ("the code rests on what the passage implies rather "
|
|
31
|
+
"than on what it says"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def reading_label(value: Any) -> Optional[str]:
|
|
36
|
+
"""The label a value names ('explicit' or 'interpretive'), or None.
|
|
37
|
+
|
|
38
|
+
Only a string can name one: a list, a number or a boolean is no label
|
|
39
|
+
(and a list cannot even be looked up in a dict)."""
|
|
40
|
+
return value if isinstance(value, str) and value in READING_LABELS else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def reading_in_words(reading: Optional[str]) -> Optional[str]:
|
|
44
|
+
"""'explicit (the passage states what the code names)', or None for
|
|
45
|
+
no label."""
|
|
46
|
+
label = reading_label(reading)
|
|
47
|
+
if label is None:
|
|
48
|
+
return None
|
|
49
|
+
return f"{label} ({READING_LABELS[label]})"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def guids_in_more_than_one(*lists: Optional[List[Any]]) -> List[Any]:
|
|
53
|
+
"""GUIDs that appear in more than one of the decision lists, in order.
|
|
54
|
+
|
|
55
|
+
A GUID sent to approve and to reject used to be counted as both and
|
|
56
|
+
end rejected; the decision tools refuse such a call instead."""
|
|
57
|
+
seen: Dict[Any, set] = {}
|
|
58
|
+
for index, values in enumerate(lists):
|
|
59
|
+
for value in (values or []):
|
|
60
|
+
key = value if isinstance(value, (str, int)) else repr(value)
|
|
61
|
+
seen.setdefault(key, set()).add(index)
|
|
62
|
+
return [key for key, where in seen.items() if len(where) > 1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# What the server writes as a proposal's merged_into: a GUID, uuid4's own
|
|
66
|
+
# shape. Anything else in a session file (crafted or damaged) is not
|
|
67
|
+
# echoed (fix round 1); matched whole, so no trailing newline gets
|
|
68
|
+
# through (fix round 2)
|
|
69
|
+
_GUID_SHAPE = re.compile(
|
|
70
|
+
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
71
|
+
r"[0-9a-fA-F]{12}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def unique_in_order(values: Optional[List[Any]]) -> List[Any]:
|
|
75
|
+
"""Each value once, first mention first: a GUID named twice in one
|
|
76
|
+
list is one decision, and is counted once."""
|
|
77
|
+
out: List[Any] = []
|
|
78
|
+
for value in (values or []):
|
|
79
|
+
if value not in out:
|
|
80
|
+
out.append(value)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def memo_with_reading(reasoning: str, reading: Optional[str]) -> str:
|
|
85
|
+
"""The text an applied suggestion carries in its coding memo, and in a
|
|
86
|
+
REFI-QDA export's selection description: the reading in words
|
|
87
|
+
FIRST, then the reason. First, because a reason holding QualCoder's
|
|
88
|
+
'#####' private marker keeps only what comes before it, and the label
|
|
89
|
+
must survive that. No label (a pre-v0.14 suggestion, or a coding read
|
|
90
|
+
back from the project for an export): the text exactly as given, not
|
|
91
|
+
trimmed, since a project export carries every memo as QualCoder
|
|
92
|
+
stores it. Never a number (owner ruling 21)."""
|
|
93
|
+
label = reading_in_words(reading)
|
|
94
|
+
if label is None:
|
|
95
|
+
return reasoning or ""
|
|
96
|
+
parts = [f"Reading: {label}", (reasoning or "").strip()]
|
|
97
|
+
return "\n\n".join(part for part in parts if part)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CodingSuggestion:
|
|
101
|
+
"""Data class for AI-suggested coding with conversational review support."""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
file_id: int,
|
|
106
|
+
file_name: str,
|
|
107
|
+
code_id: int,
|
|
108
|
+
code_name: str,
|
|
109
|
+
start_pos: int,
|
|
110
|
+
end_pos: int,
|
|
111
|
+
segment_text: str,
|
|
112
|
+
reasoning: str = "",
|
|
113
|
+
reading: Optional[str] = None,
|
|
114
|
+
status: str = "pending",
|
|
115
|
+
guid: Optional[str] = None,
|
|
116
|
+
span_alternatives: Optional[List[Dict[str, Any]]] = None,
|
|
117
|
+
adjusted: bool = False,
|
|
118
|
+
applied_ctid: Optional[int] = None,
|
|
119
|
+
reading_cleared: bool = False,
|
|
120
|
+
):
|
|
121
|
+
self.file_id = file_id
|
|
122
|
+
self.file_name = file_name
|
|
123
|
+
self.code_id = code_id
|
|
124
|
+
self.code_name = code_name
|
|
125
|
+
self.start_pos = start_pos
|
|
126
|
+
self.end_pos = end_pos
|
|
127
|
+
self.segment_text = segment_text
|
|
128
|
+
self.reasoning = reasoning # Why this segment was coded
|
|
129
|
+
# 'explicit' | 'interpretive' | None (recorded before v0.14, or a
|
|
130
|
+
# row read back from the project, which carries no label)
|
|
131
|
+
self.reading = reading_label(reading)
|
|
132
|
+
# True when edit_suggestion moved the suggestion to another code
|
|
133
|
+
# without a new label: the old one was given for the old code, so
|
|
134
|
+
# it is cleared rather than carried (fix round 1)
|
|
135
|
+
self.reading_cleared = bool(reading_cleared) and self.reading is None
|
|
136
|
+
# 'pending', 'approved', 'rejected', 'applied', or 'removed' (it
|
|
137
|
+
# was applied, then its coding was deleted with delete_coding)
|
|
138
|
+
self.status = status
|
|
139
|
+
# No surrounding text is kept (owner ruling 25, question 9): the
|
|
140
|
+
# review reads it from the file when it is shown, and a stored
|
|
141
|
+
# context_before/context_after from an earlier file is neither
|
|
142
|
+
# shown nor written back
|
|
143
|
+
self.guid = guid or str(uuid.uuid4())
|
|
144
|
+
# Server-computed ready-made span adjustments (shorter/longer).
|
|
145
|
+
# Presentational only: use_alternative recomputes from the current
|
|
146
|
+
# fulltext at edit time. [] for pre-v0.8 sessions (zero migration).
|
|
147
|
+
# A span's text is not kept (owner ruling 25, question 9): a
|
|
148
|
+
# "preview" an earlier release stored is dropped here, so it is
|
|
149
|
+
# never shown and is gone at the next save
|
|
150
|
+
self.span_alternatives = [
|
|
151
|
+
{k: v for k, v in alt.items() if k != "preview"}
|
|
152
|
+
if isinstance(alt, dict) else alt
|
|
153
|
+
for alt in (span_alternatives or [])]
|
|
154
|
+
# True once the researcher edited this suggestion (span or code) —
|
|
155
|
+
# review stops offering alternatives on decided-and-adjusted spans
|
|
156
|
+
self.adjusted = bool(adjusted)
|
|
157
|
+
# The ctid apply_codings wrote (or found) for this suggestion, so
|
|
158
|
+
# delete_coding can tell the session which suggestion it undid.
|
|
159
|
+
# None before v0.14 and until the suggestion is applied.
|
|
160
|
+
self.applied_ctid = (applied_ctid if isinstance(applied_ctid, int)
|
|
161
|
+
and not isinstance(applied_ctid, bool) else None)
|
|
162
|
+
|
|
163
|
+
# For backwards compatibility with old ai_memo field
|
|
164
|
+
self.ai_memo = reasoning
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
167
|
+
"""Convert to dictionary for JSON serialisation."""
|
|
168
|
+
return {
|
|
169
|
+
"file_id": self.file_id,
|
|
170
|
+
"file_name": self.file_name,
|
|
171
|
+
"code_id": self.code_id,
|
|
172
|
+
"code_name": self.code_name,
|
|
173
|
+
"start_pos": self.start_pos,
|
|
174
|
+
"end_pos": self.end_pos,
|
|
175
|
+
"segment_text": self.segment_text,
|
|
176
|
+
"reasoning": self.reasoning,
|
|
177
|
+
"reading": self.reading,
|
|
178
|
+
"status": self.status,
|
|
179
|
+
"guid": self.guid,
|
|
180
|
+
"span_alternatives": self.span_alternatives,
|
|
181
|
+
"adjusted": self.adjusted,
|
|
182
|
+
"applied_ctid": self.applied_ctid,
|
|
183
|
+
"reading_cleared": self.reading_cleared,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_REQUIRED_FIELDS = {
|
|
187
|
+
"file_id", "file_name", "code_id", "code_name",
|
|
188
|
+
"start_pos", "end_pos", "segment_text"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'CodingSuggestion':
|
|
193
|
+
"""Create from dictionary.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If required fields are missing
|
|
197
|
+
TypeError: If data is not a dictionary
|
|
198
|
+
"""
|
|
199
|
+
if not isinstance(data, dict):
|
|
200
|
+
raise TypeError("CodingSuggestion data must be a dictionary")
|
|
201
|
+
|
|
202
|
+
missing = cls._REQUIRED_FIELDS - set(data.keys())
|
|
203
|
+
if missing:
|
|
204
|
+
raise ValueError(f"Missing required fields: {missing}")
|
|
205
|
+
|
|
206
|
+
# Handle both old (ai_memo) and new (reasoning) formats
|
|
207
|
+
reasoning = data.get("reasoning") or data.get("ai_memo", "")
|
|
208
|
+
|
|
209
|
+
return cls(
|
|
210
|
+
file_id=data["file_id"],
|
|
211
|
+
file_name=data["file_name"],
|
|
212
|
+
code_id=data["code_id"],
|
|
213
|
+
code_name=data["code_name"],
|
|
214
|
+
start_pos=data["start_pos"],
|
|
215
|
+
end_pos=data["end_pos"],
|
|
216
|
+
segment_text=data["segment_text"],
|
|
217
|
+
reasoning=reasoning,
|
|
218
|
+
# A pre-v0.14 file carries "confidence" (a number) and no
|
|
219
|
+
# label: it loads with none, and the number is dropped. A file
|
|
220
|
+
# written while the label was called "support" loads it as
|
|
221
|
+
# the reading
|
|
222
|
+
reading=(data.get("reading") if "reading" in data
|
|
223
|
+
else data.get("support")),
|
|
224
|
+
status=data.get("status", "pending"),
|
|
225
|
+
guid=data.get("guid"),
|
|
226
|
+
span_alternatives=data.get("span_alternatives"),
|
|
227
|
+
adjusted=data.get("adjusted", False),
|
|
228
|
+
applied_ctid=data.get("applied_ctid"),
|
|
229
|
+
reading_cleared=(data.get("reading_cleared")
|
|
230
|
+
or data.get("support_cleared", False)),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class ProposedCode:
|
|
235
|
+
"""A brand-new code the AI proposes from the data (inductive coding).
|
|
236
|
+
|
|
237
|
+
Distinct from a CodingSuggestion: a proposal carries a code definition
|
|
238
|
+
(name/colour/category/memo) plus evidence spans, and only becomes a
|
|
239
|
+
real code when the user approves it and create_proposed_codes runs.
|
|
240
|
+
Status lifecycle: pending -> approved/rejected -> created; a proposal
|
|
241
|
+
merged into another is "merged", which is final (v0.14): it can never
|
|
242
|
+
be approved or created, so its evidence is never written twice. Any
|
|
243
|
+
change to an approved proposal returns it to pending.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
name: str,
|
|
249
|
+
memo: str = "",
|
|
250
|
+
rationale: str = "",
|
|
251
|
+
color: Optional[str] = None,
|
|
252
|
+
category: Optional[str] = None,
|
|
253
|
+
status: str = "pending",
|
|
254
|
+
example_segments: Optional[List[Dict[str, Any]]] = None,
|
|
255
|
+
collides_with: Optional[str] = None,
|
|
256
|
+
created_code_id: Optional[int] = None,
|
|
257
|
+
guid: Optional[str] = None,
|
|
258
|
+
merged_into: Optional[str] = None,
|
|
259
|
+
):
|
|
260
|
+
self.name = name
|
|
261
|
+
self.memo = memo # the code definition
|
|
262
|
+
self.rationale = rationale # why this code emerges
|
|
263
|
+
self.color = color # None -> palette pick at creation
|
|
264
|
+
self.category = category # existing category NAME or None
|
|
265
|
+
self.status = status # pending/approved/rejected/created/merged
|
|
266
|
+
self.example_segments = example_segments or []
|
|
267
|
+
self.collides_with = collides_with # existing code name, if any
|
|
268
|
+
self.created_code_id = created_code_id
|
|
269
|
+
self.guid = guid or str(uuid.uuid4())
|
|
270
|
+
# the target's GUID, once merged; only that shape is kept, since it
|
|
271
|
+
# is echoed into the conversation
|
|
272
|
+
self.merged_into = (merged_into if isinstance(merged_into, str)
|
|
273
|
+
and _GUID_SHAPE.fullmatch(merged_into) else None)
|
|
274
|
+
|
|
275
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
276
|
+
return {
|
|
277
|
+
"guid": self.guid,
|
|
278
|
+
"name": self.name,
|
|
279
|
+
"memo": self.memo,
|
|
280
|
+
"rationale": self.rationale,
|
|
281
|
+
"color": self.color,
|
|
282
|
+
"category": self.category,
|
|
283
|
+
"status": self.status,
|
|
284
|
+
"example_segments": self.example_segments,
|
|
285
|
+
"collides_with": self.collides_with,
|
|
286
|
+
"created_code_id": self.created_code_id,
|
|
287
|
+
"merged_into": self.merged_into,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'ProposedCode':
|
|
292
|
+
if not isinstance(data, dict):
|
|
293
|
+
raise TypeError("ProposedCode data must be a dictionary")
|
|
294
|
+
if "name" not in data:
|
|
295
|
+
raise ValueError("Missing required field: name")
|
|
296
|
+
return cls(
|
|
297
|
+
name=data["name"],
|
|
298
|
+
memo=data.get("memo", ""),
|
|
299
|
+
rationale=data.get("rationale", ""),
|
|
300
|
+
color=data.get("color"),
|
|
301
|
+
category=data.get("category"),
|
|
302
|
+
status=data.get("status", "pending"),
|
|
303
|
+
# Shorter and longer spans are no longer stored on proposal
|
|
304
|
+
# passages (owner ruling 25, question 8): a session file
|
|
305
|
+
# written earlier loses them when it is next saved
|
|
306
|
+
example_segments=[
|
|
307
|
+
{k: v for k, v in seg.items() if k != "span_alternatives"}
|
|
308
|
+
if isinstance(seg, dict) else seg
|
|
309
|
+
for seg in (data.get("example_segments") or [])],
|
|
310
|
+
collides_with=data.get("collides_with"),
|
|
311
|
+
created_code_id=data.get("created_code_id"),
|
|
312
|
+
guid=data.get("guid"),
|
|
313
|
+
merged_into=data.get("merged_into"),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class AICodingSession:
|
|
318
|
+
"""Manages a session of AI coding suggestions."""
|
|
319
|
+
|
|
320
|
+
def __init__(
|
|
321
|
+
self,
|
|
322
|
+
project_path: str,
|
|
323
|
+
session_id: Optional[str] = None,
|
|
324
|
+
description: str = "",
|
|
325
|
+
file_ids: Optional[List[int]] = None,
|
|
326
|
+
code_names: Optional[List[str]] = None,
|
|
327
|
+
instruction: str = "",
|
|
328
|
+
ai_coder_name_at_record: Optional[str] = None,
|
|
329
|
+
scope: Optional[Dict[str, Any]] = None
|
|
330
|
+
):
|
|
331
|
+
self.session_id = session_id or str(uuid.uuid4())
|
|
332
|
+
# What record_suggestions may record in this session (v0.14, the
|
|
333
|
+
# claims audit's item 10): {"file_ids": [...], "code_ids": [...] or
|
|
334
|
+
# None for every code}. None for a session made before v0.14 (or
|
|
335
|
+
# built directly), whose file_ids and code_names were only ever a
|
|
336
|
+
# note: those sessions keep that behaviour.
|
|
337
|
+
self.scope = self._clean_scope(scope)
|
|
338
|
+
# A scope that is there but cannot be read (a damaged or hand-made
|
|
339
|
+
# file) fails closed: nothing is recorded into the session, rather
|
|
340
|
+
# than every limit being lifted as for a session with none (fix
|
|
341
|
+
# round 1). The raw value is written back unchanged.
|
|
342
|
+
self.scope_unreadable = scope is not None and self.scope is None
|
|
343
|
+
self._raw_scope = scope if self.scope_unreadable else None
|
|
344
|
+
# The project's AI coder name AT THE MOMENT the session was
|
|
345
|
+
# created (v0.12, D7 section 7). Suggestions recorded under one
|
|
346
|
+
# name and applied after the researcher changed it are still
|
|
347
|
+
# written under the CURRENT name, because a name change never
|
|
348
|
+
# re-attributes anything; the snapshot exists so the apply can
|
|
349
|
+
# SAY so. Absent in 0.11 session files, which is why it is
|
|
350
|
+
# optional here and read with a default below.
|
|
351
|
+
self.ai_coder_name_at_record = ai_coder_name_at_record
|
|
352
|
+
# Ensure project_path is always a string for JSON serialization
|
|
353
|
+
self.project_path = str(project_path) if project_path else ""
|
|
354
|
+
self.description = description
|
|
355
|
+
self.file_ids = file_ids or []
|
|
356
|
+
self.code_names = code_names or []
|
|
357
|
+
self.instruction = instruction
|
|
358
|
+
self.suggestions: List[CodingSuggestion] = []
|
|
359
|
+
self.proposed_codes: List[ProposedCode] = []
|
|
360
|
+
# Span-affordance bookkeeping (tester-feedback amendment): counts
|
|
361
|
+
# drive the one-time shortcut hint (first manual span edit) and the
|
|
362
|
+
# calibration-escalation hint (3 same-direction alternative picks)
|
|
363
|
+
self.span_edit_stats: Dict[str, int] = {
|
|
364
|
+
"manual_edits": 0, "shorter_picks": 0, "longer_picks": 0}
|
|
365
|
+
self.created_at = datetime.now().isoformat()
|
|
366
|
+
self.last_modified = self.created_at
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _clean_scope(scope: Any) -> Optional[Dict[str, Any]]:
|
|
370
|
+
"""A scope read from disk, or None if it is not a well-formed one."""
|
|
371
|
+
def ids(value):
|
|
372
|
+
if not isinstance(value, list):
|
|
373
|
+
return None
|
|
374
|
+
if not all(isinstance(v, int) and not isinstance(v, bool)
|
|
375
|
+
for v in value):
|
|
376
|
+
return None
|
|
377
|
+
return list(value)
|
|
378
|
+
if not isinstance(scope, dict):
|
|
379
|
+
return None
|
|
380
|
+
file_ids = ids(scope.get("file_ids"))
|
|
381
|
+
if file_ids is None:
|
|
382
|
+
return None
|
|
383
|
+
code_ids = scope.get("code_ids")
|
|
384
|
+
if code_ids is not None:
|
|
385
|
+
code_ids = ids(code_ids)
|
|
386
|
+
if code_ids is None:
|
|
387
|
+
return None
|
|
388
|
+
return {"file_ids": file_ids, "code_ids": code_ids}
|
|
389
|
+
|
|
390
|
+
def outside_scope(self, file_id: int,
|
|
391
|
+
code_id: Optional[int] = None) -> Optional[str]:
|
|
392
|
+
"""Which part of the scope a file or code falls outside, or None.
|
|
393
|
+
|
|
394
|
+
'file' or 'code'; 'unreadable' when the session's scope is there but
|
|
395
|
+
cannot be read; None when the session has no scope (made before
|
|
396
|
+
v0.14) or both are inside it. code_id None checks the file only."""
|
|
397
|
+
if self.scope_unreadable:
|
|
398
|
+
return "unreadable"
|
|
399
|
+
if self.scope is None:
|
|
400
|
+
return None
|
|
401
|
+
if file_id not in self.scope["file_ids"]:
|
|
402
|
+
return "file"
|
|
403
|
+
codes = self.scope["code_ids"]
|
|
404
|
+
if code_id is not None and codes is not None and code_id not in codes:
|
|
405
|
+
return "code"
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
def add_codes_to_scope(self, code_ids: List[int]) -> None:
|
|
409
|
+
"""Codes created from this session's approved proposals join a
|
|
410
|
+
session limited to named codes, so the loop can apply them."""
|
|
411
|
+
if self.scope is not None and self.scope["code_ids"] is not None:
|
|
412
|
+
for cid in code_ids:
|
|
413
|
+
if cid not in self.scope["code_ids"]:
|
|
414
|
+
self.scope["code_ids"].append(cid)
|
|
415
|
+
|
|
416
|
+
def add_suggestion(self, suggestion: CodingSuggestion):
|
|
417
|
+
"""Add a coding suggestion to the session."""
|
|
418
|
+
self.suggestions.append(suggestion)
|
|
419
|
+
self.last_modified = datetime.now().isoformat()
|
|
420
|
+
|
|
421
|
+
def add_proposal(self, proposal: 'ProposedCode'):
|
|
422
|
+
"""Add a proposed code to the session."""
|
|
423
|
+
self.proposed_codes.append(proposal)
|
|
424
|
+
self.last_modified = datetime.now().isoformat()
|
|
425
|
+
|
|
426
|
+
def get_proposal_by_guid(self, guid: str) -> Optional['ProposedCode']:
|
|
427
|
+
for p in self.proposed_codes:
|
|
428
|
+
if p.guid == guid:
|
|
429
|
+
return p
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
def proposal_statistics(self) -> Dict[str, int]:
|
|
433
|
+
counts = {"total_proposals": len(self.proposed_codes),
|
|
434
|
+
"pending": 0, "approved": 0, "rejected": 0, "created": 0,
|
|
435
|
+
"merged": 0}
|
|
436
|
+
for p in self.proposed_codes:
|
|
437
|
+
if p.status in counts:
|
|
438
|
+
counts[p.status] += 1
|
|
439
|
+
return counts
|
|
440
|
+
|
|
441
|
+
def update_proposals_by_guid(
|
|
442
|
+
self,
|
|
443
|
+
approve: Optional[List[str]] = None,
|
|
444
|
+
reject: Optional[List[str]] = None
|
|
445
|
+
) -> Dict[str, Any]:
|
|
446
|
+
"""Approve/reject proposals. CREATED proposals are immutable
|
|
447
|
+
(they are already in the codebook) and MERGED ones are final
|
|
448
|
+
(v0.14): both are skipped and counted, mirroring the
|
|
449
|
+
applied-immutable rule for suggestions (QA2-2). GUIDs that name
|
|
450
|
+
no proposal in this session come back in `not_found`. Each GUID is
|
|
451
|
+
counted once, and `approved` and `rejected` count only proposals
|
|
452
|
+
whose status moved; one that already had that status is counted
|
|
453
|
+
in `unchanged` (fix round 1: the counts are what the researcher
|
|
454
|
+
checks against what they said). The caller refuses a GUID given
|
|
455
|
+
in both lists before calling."""
|
|
456
|
+
counts = {"approved": 0, "rejected": 0, "unchanged": 0,
|
|
457
|
+
"skipped_created": 0, "skipped_merged": 0, "changed": 0}
|
|
458
|
+
not_found: List[Any] = []
|
|
459
|
+
for guids, status in ((approve, "approved"), (reject, "rejected")):
|
|
460
|
+
for guid in unique_in_order(guids):
|
|
461
|
+
p = self.get_proposal_by_guid(guid)
|
|
462
|
+
if p is None:
|
|
463
|
+
not_found.append(guid)
|
|
464
|
+
continue
|
|
465
|
+
if p.status in ("created", "merged"):
|
|
466
|
+
counts[f"skipped_{p.status}"] += 1
|
|
467
|
+
continue
|
|
468
|
+
if p.status == status:
|
|
469
|
+
counts["unchanged"] += 1
|
|
470
|
+
continue
|
|
471
|
+
p.status = status
|
|
472
|
+
counts[status] += 1
|
|
473
|
+
counts["changed"] += 1
|
|
474
|
+
if counts["changed"]:
|
|
475
|
+
self.last_modified = datetime.now().isoformat()
|
|
476
|
+
return {**counts, "not_found": not_found}
|
|
477
|
+
|
|
478
|
+
def get_suggestions_by_file(self, file_id: int) -> List[CodingSuggestion]:
|
|
479
|
+
"""Get all suggestions for a specific file."""
|
|
480
|
+
return [s for s in self.suggestions if s.file_id == file_id]
|
|
481
|
+
|
|
482
|
+
def get_suggestions_by_code(self, code_id: int) -> List[CodingSuggestion]:
|
|
483
|
+
"""Get all suggestions for a specific code."""
|
|
484
|
+
return [s for s in self.suggestions if s.code_id == code_id]
|
|
485
|
+
|
|
486
|
+
def filter_by_status(self, status: str) -> List[CodingSuggestion]:
|
|
487
|
+
"""Get suggestions by status (pending/approved/rejected)."""
|
|
488
|
+
return [s for s in self.suggestions if s.status == status]
|
|
489
|
+
|
|
490
|
+
def get_statistics(self) -> Dict[str, Any]:
|
|
491
|
+
"""Get session statistics."""
|
|
492
|
+
total = len(self.suggestions)
|
|
493
|
+
approved = len([s for s in self.suggestions if s.status == "approved"])
|
|
494
|
+
rejected = len([s for s in self.suggestions if s.status == "rejected"])
|
|
495
|
+
pending = len([s for s in self.suggestions if s.status == "pending"])
|
|
496
|
+
applied = len([s for s in self.suggestions if s.status == "applied"])
|
|
497
|
+
removed = len([s for s in self.suggestions if s.status == "removed"])
|
|
498
|
+
|
|
499
|
+
# By file
|
|
500
|
+
by_file = {}
|
|
501
|
+
for s in self.suggestions:
|
|
502
|
+
if s.file_name not in by_file:
|
|
503
|
+
by_file[s.file_name] = 0
|
|
504
|
+
by_file[s.file_name] += 1
|
|
505
|
+
|
|
506
|
+
# By code
|
|
507
|
+
by_code = {}
|
|
508
|
+
for s in self.suggestions:
|
|
509
|
+
if s.code_name not in by_code:
|
|
510
|
+
by_code[s.code_name] = 0
|
|
511
|
+
by_code[s.code_name] += 1
|
|
512
|
+
|
|
513
|
+
return {
|
|
514
|
+
"total_suggestions": total,
|
|
515
|
+
"approved": approved,
|
|
516
|
+
"rejected": rejected,
|
|
517
|
+
"pending": pending,
|
|
518
|
+
"applied": applied,
|
|
519
|
+
"removed": removed,
|
|
520
|
+
"by_file": by_file,
|
|
521
|
+
"by_code": by_code
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
def has_duplicate(self, file_id: int, code_id: int,
|
|
525
|
+
start_pos: int, end_pos: int) -> bool:
|
|
526
|
+
"""Check whether an equivalent suggestion is already in the session.
|
|
527
|
+
|
|
528
|
+
A suggestion whose coding was deleted (status "removed") does not
|
|
529
|
+
count: the same passage can be recorded again after the undo."""
|
|
530
|
+
for s in self.suggestions:
|
|
531
|
+
if s.status == "removed":
|
|
532
|
+
continue
|
|
533
|
+
if (s.file_id == file_id and s.code_id == code_id
|
|
534
|
+
and s.start_pos == start_pos and s.end_pos == end_pos):
|
|
535
|
+
return True
|
|
536
|
+
return False
|
|
537
|
+
|
|
538
|
+
def remove_pending_suggestions(self) -> int:
|
|
539
|
+
"""Remove all pending suggestions (used by record_suggestions replace).
|
|
540
|
+
|
|
541
|
+
Approved, rejected, and applied suggestions are never removed.
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
Number of suggestions removed
|
|
545
|
+
"""
|
|
546
|
+
before = len(self.suggestions)
|
|
547
|
+
self.suggestions = [s for s in self.suggestions if s.status != "pending"]
|
|
548
|
+
removed = before - len(self.suggestions)
|
|
549
|
+
if removed:
|
|
550
|
+
self.last_modified = datetime.now().isoformat()
|
|
551
|
+
return removed
|
|
552
|
+
|
|
553
|
+
def mark_applied(self, guids: List[str],
|
|
554
|
+
ctids: Optional[Dict[str, int]] = None) -> int:
|
|
555
|
+
"""Mark suggestions as applied (written to the database).
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
guids: GUIDs of the suggestions that were written
|
|
559
|
+
ctids: the coding each one became, by GUID, kept so that
|
|
560
|
+
delete_coding can find the suggestion it undoes
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Number of suggestions marked
|
|
564
|
+
"""
|
|
565
|
+
count = 0
|
|
566
|
+
for guid in guids:
|
|
567
|
+
sugg = self.get_suggestion_by_guid(guid)
|
|
568
|
+
if sugg is not None:
|
|
569
|
+
sugg.status = "applied"
|
|
570
|
+
if ctids and guid in ctids:
|
|
571
|
+
sugg.applied_ctid = ctids[guid]
|
|
572
|
+
count += 1
|
|
573
|
+
if count:
|
|
574
|
+
self.last_modified = datetime.now().isoformat()
|
|
575
|
+
return count
|
|
576
|
+
|
|
577
|
+
def get_suggestion_by_guid(self, guid: str) -> Optional[CodingSuggestion]:
|
|
578
|
+
"""Get a suggestion by its GUID."""
|
|
579
|
+
for s in self.suggestions:
|
|
580
|
+
if s.guid == guid:
|
|
581
|
+
return s
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
def update_suggestion_status(self, index: int, status: str) -> bool:
|
|
585
|
+
"""Update the status of a suggestion by index.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
index: Index of the suggestion in the list
|
|
589
|
+
status: New status ('approved' or 'rejected')
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
True if updated, False if index out of range
|
|
593
|
+
"""
|
|
594
|
+
if 0 <= index < len(self.suggestions):
|
|
595
|
+
self.suggestions[index].status = status
|
|
596
|
+
self.last_modified = datetime.now().isoformat()
|
|
597
|
+
return True
|
|
598
|
+
return False
|
|
599
|
+
|
|
600
|
+
def update_suggestions_by_guid(
|
|
601
|
+
self,
|
|
602
|
+
approve: Optional[List[str]] = None,
|
|
603
|
+
reject: Optional[List[str]] = None,
|
|
604
|
+
reopen: Optional[List[str]] = None
|
|
605
|
+
) -> Dict[str, Any]:
|
|
606
|
+
"""Approve, reject or reopen suggestions by GUID.
|
|
607
|
+
|
|
608
|
+
Suggestions already APPLIED to the database are immutable here:
|
|
609
|
+
re-approving one would undo the double-apply bookkeeping and make
|
|
610
|
+
the next apply_codings fail wholesale on the duplicate constraint
|
|
611
|
+
(QA2-2). They are skipped and counted. `reopen` returns an
|
|
612
|
+
approved, rejected or removed suggestion to pending, so it can be
|
|
613
|
+
edited and decided again (v0.14; before it, nothing could, and
|
|
614
|
+
edit_suggestion's own advice went in a circle). GUIDs that name no
|
|
615
|
+
suggestion in this session come back in `not_found`. Each GUID is
|
|
616
|
+
counted once, and approved, rejected and reopened count only the
|
|
617
|
+
suggestions whose status moved; one that already had that status
|
|
618
|
+
is counted in `unchanged` (fix round 1: the researcher checks the
|
|
619
|
+
approved number against what they said yes to). The caller
|
|
620
|
+
refuses a GUID given in more than one list before calling.
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
Counts of approved, rejected, reopened, unchanged,
|
|
624
|
+
skipped_applied and changed, and the not_found list
|
|
625
|
+
"""
|
|
626
|
+
counts = {"approved": 0, "rejected": 0, "reopened": 0,
|
|
627
|
+
"unchanged": 0, "skipped_applied": 0, "changed": 0}
|
|
628
|
+
not_found: List[Any] = []
|
|
629
|
+
for guids, status, key in ((approve, "approved", "approved"),
|
|
630
|
+
(reject, "rejected", "rejected"),
|
|
631
|
+
(reopen, "pending", "reopened")):
|
|
632
|
+
for guid in unique_in_order(guids):
|
|
633
|
+
sugg = self.get_suggestion_by_guid(guid)
|
|
634
|
+
if sugg is None:
|
|
635
|
+
not_found.append(guid)
|
|
636
|
+
continue
|
|
637
|
+
if sugg.status == "applied":
|
|
638
|
+
counts["skipped_applied"] += 1
|
|
639
|
+
continue
|
|
640
|
+
if sugg.status == status:
|
|
641
|
+
counts["unchanged"] += 1
|
|
642
|
+
continue
|
|
643
|
+
sugg.status = status
|
|
644
|
+
counts[key] += 1
|
|
645
|
+
counts["changed"] += 1
|
|
646
|
+
if counts["changed"]:
|
|
647
|
+
self.last_modified = datetime.now().isoformat()
|
|
648
|
+
return {**counts, "not_found": not_found}
|
|
649
|
+
|
|
650
|
+
def mark_removed(self, file_id: int, code_id: int, start_pos: int,
|
|
651
|
+
end_pos: int, ctid: int, owner_is_ai: bool) -> List[str]:
|
|
652
|
+
"""The applied suggestions a deleted coding undid: marked removed.
|
|
653
|
+
|
|
654
|
+
A suggestion matches when its file, code and span are the deleted
|
|
655
|
+
row's, the deleted row was written under one of the project's AI
|
|
656
|
+
coder names, and, where apply_codings recorded the ctid it wrote
|
|
657
|
+
(v0.14 on), that ctid is the deleted one. The owner is checked in
|
|
658
|
+
every case: SQLite hands the highest coding id out again once its
|
|
659
|
+
row is gone, so a person's coding of the same span can carry the
|
|
660
|
+
id an AI coding had (fix round 1). Deleting a person's coding
|
|
661
|
+
never touches a suggestion.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
The GUIDs marked
|
|
665
|
+
"""
|
|
666
|
+
marked = []
|
|
667
|
+
for sugg in self.suggestions:
|
|
668
|
+
if (sugg.status != "applied" or sugg.file_id != file_id
|
|
669
|
+
or sugg.code_id != code_id
|
|
670
|
+
or sugg.start_pos != start_pos
|
|
671
|
+
or sugg.end_pos != end_pos):
|
|
672
|
+
continue
|
|
673
|
+
if not owner_is_ai:
|
|
674
|
+
continue
|
|
675
|
+
if sugg.applied_ctid is not None and sugg.applied_ctid != ctid:
|
|
676
|
+
continue
|
|
677
|
+
sugg.status = "removed"
|
|
678
|
+
marked.append(sugg.guid)
|
|
679
|
+
if marked:
|
|
680
|
+
self.last_modified = datetime.now().isoformat()
|
|
681
|
+
return marked
|
|
682
|
+
|
|
683
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
684
|
+
"""Export session as dictionary for JSON export."""
|
|
685
|
+
return {
|
|
686
|
+
"session_id": self.session_id,
|
|
687
|
+
"created_at": self.created_at,
|
|
688
|
+
"last_modified": self.last_modified,
|
|
689
|
+
"project_path": self.project_path,
|
|
690
|
+
"description": self.description,
|
|
691
|
+
"file_ids": self.file_ids,
|
|
692
|
+
"code_names": self.code_names,
|
|
693
|
+
"instruction": self.instruction,
|
|
694
|
+
"scope": self._raw_scope if self.scope_unreadable else self.scope,
|
|
695
|
+
"ai_coder_name_at_record": self.ai_coder_name_at_record,
|
|
696
|
+
"suggestions": [s.to_dict() for s in self.suggestions],
|
|
697
|
+
"proposed_codes": [p.to_dict() for p in self.proposed_codes],
|
|
698
|
+
"span_edit_stats": self.span_edit_stats,
|
|
699
|
+
"statistics": self.get_statistics(),
|
|
700
|
+
"proposal_statistics": self.proposal_statistics()
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
_REQUIRED_FIELDS = {
|
|
704
|
+
"project_path", "session_id", "created_at", "last_modified"
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
@classmethod
|
|
708
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'AICodingSession':
|
|
709
|
+
"""Create session from dictionary.
|
|
710
|
+
|
|
711
|
+
Raises:
|
|
712
|
+
ValueError: If required fields are missing
|
|
713
|
+
TypeError: If data is not a dictionary
|
|
714
|
+
"""
|
|
715
|
+
if not isinstance(data, dict):
|
|
716
|
+
raise TypeError("AICodingSession data must be a dictionary")
|
|
717
|
+
|
|
718
|
+
missing = cls._REQUIRED_FIELDS - set(data.keys())
|
|
719
|
+
if missing:
|
|
720
|
+
raise ValueError(f"Missing required session fields: {missing}")
|
|
721
|
+
|
|
722
|
+
session = cls(
|
|
723
|
+
project_path=data["project_path"],
|
|
724
|
+
session_id=data["session_id"],
|
|
725
|
+
description=data.get("description", ""),
|
|
726
|
+
file_ids=data.get("file_ids", []),
|
|
727
|
+
code_names=data.get("code_names", []),
|
|
728
|
+
instruction=data.get("instruction", ""),
|
|
729
|
+
# A pre-v0.14 file's "min_confidence" is read past: it never
|
|
730
|
+
# filtered anything, and the score it referred to is gone
|
|
731
|
+
# Absent in 0.11 files: no snapshot, so no warning to give
|
|
732
|
+
ai_coder_name_at_record=data.get("ai_coder_name_at_record"),
|
|
733
|
+
# Absent before v0.14: no scope, so nothing is refused
|
|
734
|
+
scope=data.get("scope")
|
|
735
|
+
)
|
|
736
|
+
session.created_at = data["created_at"]
|
|
737
|
+
session.last_modified = data["last_modified"]
|
|
738
|
+
# Absent in pre-v0.8 sessions -> fresh counters (zero migration)
|
|
739
|
+
stats = data.get("span_edit_stats")
|
|
740
|
+
if isinstance(stats, dict):
|
|
741
|
+
session.span_edit_stats = {
|
|
742
|
+
"manual_edits": int(stats.get("manual_edits", 0)),
|
|
743
|
+
"shorter_picks": int(stats.get("shorter_picks", 0)),
|
|
744
|
+
"longer_picks": int(stats.get("longer_picks", 0)),
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
# Load suggestions
|
|
748
|
+
for s_data in data.get("suggestions", []):
|
|
749
|
+
suggestion = CodingSuggestion.from_dict(s_data)
|
|
750
|
+
session.suggestions.append(suggestion)
|
|
751
|
+
|
|
752
|
+
# Load proposed codes (absent in pre-v0.8 sessions -> [])
|
|
753
|
+
for p_data in data.get("proposed_codes", []):
|
|
754
|
+
session.proposed_codes.append(ProposedCode.from_dict(p_data))
|
|
755
|
+
|
|
756
|
+
return session
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class SessionManager:
|
|
760
|
+
"""Manage AI coding sessions with disk persistence."""
|
|
761
|
+
|
|
762
|
+
# Session IDs must be valid UUID4 strings (hex + hyphens only)
|
|
763
|
+
_SESSION_ID_PATTERN = re.compile(
|
|
764
|
+
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
def __init__(self, storage_dir: Optional[str] = None):
|
|
768
|
+
# Constructing a manager must not touch the disk: the server
|
|
769
|
+
# builds one at import time, and `exegete --version` would
|
|
770
|
+
# otherwise create the storage directory before it had even read
|
|
771
|
+
# its own command line (v0.12 fix round 1, F16). The directory is
|
|
772
|
+
# created on the first save; every read path copes with its
|
|
773
|
+
# absence (a glob over a missing directory yields nothing).
|
|
774
|
+
#
|
|
775
|
+
# With no folder given (the server's own manager), the folder is
|
|
776
|
+
# `sessions` in the state folder, chosen at CALL time (v0.14.1):
|
|
777
|
+
# the state folder moves from ~/.qualcoder_mcp to ~/.exegete at
|
|
778
|
+
# the first real start, after this manager was built, and a run
|
|
779
|
+
# whose move could not be made uses the old one for everything.
|
|
780
|
+
self._storage_dir = (None if storage_dir is None
|
|
781
|
+
else Path(storage_dir).expanduser())
|
|
782
|
+
logger.debug("SessionManager storage ready")
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def storage_dir(self) -> Path:
|
|
786
|
+
if self._storage_dir is not None:
|
|
787
|
+
return self._storage_dir
|
|
788
|
+
from .preview_tokens import state_home # no import cycle
|
|
789
|
+
return state_home() / "sessions"
|
|
790
|
+
|
|
791
|
+
@storage_dir.setter
|
|
792
|
+
def storage_dir(self, value) -> None:
|
|
793
|
+
self._storage_dir = None if value is None else Path(value)
|
|
794
|
+
|
|
795
|
+
def _ensure_storage_dir(self) -> None:
|
|
796
|
+
"""Create the storage directory; called before every write."""
|
|
797
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
798
|
+
|
|
799
|
+
@classmethod
|
|
800
|
+
def _validate_session_id(cls, session_id: str) -> str:
|
|
801
|
+
"""Validate session ID to prevent path traversal.
|
|
802
|
+
|
|
803
|
+
Session IDs must be valid UUID4 format (lowercase hex with hyphens).
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
session_id: The session ID to validate
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
The validated session ID
|
|
810
|
+
|
|
811
|
+
Raises:
|
|
812
|
+
ValueError: If session ID is not valid UUID4 format
|
|
813
|
+
"""
|
|
814
|
+
if not isinstance(session_id, str):
|
|
815
|
+
raise ValueError("session_id must be a string")
|
|
816
|
+
if not cls._SESSION_ID_PATTERN.match(session_id):
|
|
817
|
+
raise ValueError(
|
|
818
|
+
f"Invalid session ID format: must be UUID4 "
|
|
819
|
+
f"(e.g., '550e8400-e29b-41d4-a716-446655440000')"
|
|
820
|
+
)
|
|
821
|
+
return session_id
|
|
822
|
+
|
|
823
|
+
def save_session(self, session: AICodingSession) -> None:
|
|
824
|
+
"""Save session to disk as JSON, atomically and owner-only.
|
|
825
|
+
|
|
826
|
+
The MRU write discipline (server.py _open_mru_tmp), applied here
|
|
827
|
+
because a session file holds the researcher's approvals and a
|
|
828
|
+
torn write would lose them: tempfile.mkstemp opens O_CREAT|O_EXCL
|
|
829
|
+
at mode 0600 under an unpredictable name in the sessions
|
|
830
|
+
directory, so a concurrent host cannot share a temp name and a
|
|
831
|
+
symlink planted at a would-be name is refused rather than written
|
|
832
|
+
through; os.replace is atomic, so a reader sees the old file or
|
|
833
|
+
the new one; a failure unlinks the temp and leaves the previous
|
|
834
|
+
file intact. No fsync: sessions are re-creatable, and the one
|
|
835
|
+
fsync in this server is the AI coder name sidecar, which is not
|
|
836
|
+
(H5, B4.5).
|
|
837
|
+
|
|
838
|
+
Args:
|
|
839
|
+
session: The AICodingSession to save
|
|
840
|
+
|
|
841
|
+
Raises:
|
|
842
|
+
ValueError: If session ID is not valid UUID4 format
|
|
843
|
+
"""
|
|
844
|
+
self._validate_session_id(session.session_id)
|
|
845
|
+
self._ensure_storage_dir()
|
|
846
|
+
filepath = self.storage_dir / f"session_{session.session_id}.json"
|
|
847
|
+
tmp = None
|
|
848
|
+
try:
|
|
849
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(self.storage_dir),
|
|
850
|
+
prefix=f"{filepath.name}.",
|
|
851
|
+
suffix=".tmp")
|
|
852
|
+
tmp = Path(tmp_name)
|
|
853
|
+
# `os.fdopen` either takes the descriptor or raises with it
|
|
854
|
+
# still open and unowned. POSIX hides that: the cleanup can
|
|
855
|
+
# unlink an open file. Windows cannot, and the storage
|
|
856
|
+
# folder keeps the temp for ever (fix round 5).
|
|
857
|
+
try:
|
|
858
|
+
handle = os.fdopen(fd, "w", encoding="utf-8")
|
|
859
|
+
except BaseException:
|
|
860
|
+
os.close(fd)
|
|
861
|
+
raise
|
|
862
|
+
with handle as f:
|
|
863
|
+
json.dump(session.to_dict(), f, indent=2)
|
|
864
|
+
os.replace(str(tmp), str(filepath))
|
|
865
|
+
logger.info(f"Saved session {session.session_id}")
|
|
866
|
+
except Exception as e:
|
|
867
|
+
if tmp is not None:
|
|
868
|
+
try:
|
|
869
|
+
tmp.unlink()
|
|
870
|
+
except OSError:
|
|
871
|
+
pass
|
|
872
|
+
logger.error(f"Failed to save session {session.session_id}: {error_label(e)}")
|
|
873
|
+
raise
|
|
874
|
+
|
|
875
|
+
def load_session(self, session_id: str) -> AICodingSession:
|
|
876
|
+
"""Load session from disk.
|
|
877
|
+
|
|
878
|
+
Args:
|
|
879
|
+
session_id: The session ID to load
|
|
880
|
+
|
|
881
|
+
Returns:
|
|
882
|
+
The loaded AICodingSession
|
|
883
|
+
|
|
884
|
+
Raises:
|
|
885
|
+
ValueError: If session ID is not valid UUID4 format
|
|
886
|
+
FileNotFoundError: If session file doesn't exist
|
|
887
|
+
"""
|
|
888
|
+
return self.load_session_and_bytes(session_id)[0]
|
|
889
|
+
|
|
890
|
+
def load_session_and_bytes(self, session_id: str):
|
|
891
|
+
"""Load a session and return it with the file's bytes as read.
|
|
892
|
+
|
|
893
|
+
One read serves both, so a caller can later tell whether another
|
|
894
|
+
writer (a second host on the same project) saved the file in the
|
|
895
|
+
meantime (save_session_if_unchanged). A file whose inner id is not
|
|
896
|
+
its name is refused: saving it would write the session its inner
|
|
897
|
+
id names, not the file that was read (fix round 1).
|
|
898
|
+
|
|
899
|
+
Raises:
|
|
900
|
+
ValueError: If the id is not UUID4, or the file holds another id
|
|
901
|
+
FileNotFoundError: If session file doesn't exist
|
|
902
|
+
"""
|
|
903
|
+
self._validate_session_id(session_id)
|
|
904
|
+
filepath = self.storage_dir / f"session_{session_id}.json"
|
|
905
|
+
if not filepath.exists():
|
|
906
|
+
raise FileNotFoundError(f"Session {session_id} not found at {filepath}")
|
|
907
|
+
|
|
908
|
+
try:
|
|
909
|
+
raw = filepath.read_bytes()
|
|
910
|
+
data = json.loads(raw.decode("utf-8"))
|
|
911
|
+
session = AICodingSession.from_dict(data)
|
|
912
|
+
if session.session_id != session_id:
|
|
913
|
+
raise ValueError(
|
|
914
|
+
f"Session file for {session_id} holds another session's "
|
|
915
|
+
f"id; it is refused rather than used or written")
|
|
916
|
+
logger.info(f"Loaded session {session_id}")
|
|
917
|
+
return session, raw
|
|
918
|
+
except Exception as e:
|
|
919
|
+
logger.error(f"Failed to load session {session_id}: {error_label(e)}")
|
|
920
|
+
raise
|
|
921
|
+
|
|
922
|
+
def save_session_if_unchanged(self, session: 'AICodingSession',
|
|
923
|
+
raw: bytes) -> bool:
|
|
924
|
+
"""Save only if the file still holds the bytes it was read with.
|
|
925
|
+
|
|
926
|
+
False, and nothing written, when another writer saved it since;
|
|
927
|
+
the caller reads it again and redoes its change on the new copy,
|
|
928
|
+
so a change made meanwhile (a reopen, say) is not undone. The
|
|
929
|
+
window left is the one between this comparison and the replace."""
|
|
930
|
+
filepath = self.storage_dir / f"session_{session.session_id}.json"
|
|
931
|
+
try:
|
|
932
|
+
if filepath.read_bytes() != raw:
|
|
933
|
+
return False
|
|
934
|
+
except OSError:
|
|
935
|
+
return False
|
|
936
|
+
self.save_session(session)
|
|
937
|
+
return True
|
|
938
|
+
|
|
939
|
+
def session_exists(self, session_id: str) -> bool:
|
|
940
|
+
"""Check if a session file exists.
|
|
941
|
+
|
|
942
|
+
Args:
|
|
943
|
+
session_id: The session ID to check
|
|
944
|
+
|
|
945
|
+
Returns:
|
|
946
|
+
True if session file exists, False otherwise.
|
|
947
|
+
Returns False for invalid session ID formats.
|
|
948
|
+
"""
|
|
949
|
+
try:
|
|
950
|
+
self._validate_session_id(session_id)
|
|
951
|
+
except ValueError:
|
|
952
|
+
return False
|
|
953
|
+
filepath = self.storage_dir / f"session_{session_id}.json"
|
|
954
|
+
return filepath.exists()
|
|
955
|
+
|
|
956
|
+
@staticmethod
|
|
957
|
+
def canonical_database_path(project_path: Any) -> Optional[str]:
|
|
958
|
+
"""A project's database path in one form, whichever form names it
|
|
959
|
+
(v0.14, the claims audit's item 2), or None for no path.
|
|
960
|
+
|
|
961
|
+
A session records its project as the database file
|
|
962
|
+
(`<project>.qda/data.qda`, from `db.db_path`), while
|
|
963
|
+
create_project and select_project given the folder record the
|
|
964
|
+
folder, and a path may carry `~`, a link or a relative part. The
|
|
965
|
+
lists compared those as plain strings, so after create_project
|
|
966
|
+
no session matched the selected project. Both sides are made the
|
|
967
|
+
resolved `data.qda` path here, without opening anything, so a
|
|
968
|
+
moved or deleted project is compared by its name as well.
|
|
969
|
+
"""
|
|
970
|
+
if project_path is None or not str(project_path).strip():
|
|
971
|
+
return None
|
|
972
|
+
try:
|
|
973
|
+
path = Path(str(project_path)).expanduser().resolve(strict=False)
|
|
974
|
+
except (OSError, RuntimeError, ValueError):
|
|
975
|
+
return str(project_path)
|
|
976
|
+
if path.is_dir() or (path.suffix.lower() == ".qda"
|
|
977
|
+
and path.name.lower() != "data.qda"
|
|
978
|
+
and not path.is_file()):
|
|
979
|
+
path = path / "data.qda"
|
|
980
|
+
return str(path)
|
|
981
|
+
|
|
982
|
+
@classmethod
|
|
983
|
+
def same_project(cls, first: Any, second: Any) -> bool:
|
|
984
|
+
"""Whether two project paths name one project's database (fix
|
|
985
|
+
round 1).
|
|
986
|
+
|
|
987
|
+
The canonical strings first, then, when they differ and both
|
|
988
|
+
databases are on disk, the files themselves (device and inode,
|
|
989
|
+
`os.path.samefile`): macOS's disk and Windows' ignore letter case
|
|
990
|
+
and macOS's Unicode form too, and `resolve()` keeps the spelling
|
|
991
|
+
it was given, so `…/study.qda` and `…/Study.qda`, or a name in
|
|
992
|
+
its composed and decomposed forms, are one project with two
|
|
993
|
+
strings. A project no longer on disk is compared by its string.
|
|
994
|
+
"""
|
|
995
|
+
a, b = (cls.canonical_database_path(first),
|
|
996
|
+
cls.canonical_database_path(second))
|
|
997
|
+
if a is None or b is None:
|
|
998
|
+
return False
|
|
999
|
+
if a == b:
|
|
1000
|
+
return True
|
|
1001
|
+
try:
|
|
1002
|
+
return os.path.samefile(a, b)
|
|
1003
|
+
except (OSError, ValueError):
|
|
1004
|
+
return False
|
|
1005
|
+
|
|
1006
|
+
@staticmethod
|
|
1007
|
+
def project_name(project_path: Any) -> str:
|
|
1008
|
+
"""The project's name as the researcher knows it, its folder's,
|
|
1009
|
+
not "data" (v0.14)."""
|
|
1010
|
+
path = Path(str(project_path))
|
|
1011
|
+
if path.name.lower() == "data.qda" and \
|
|
1012
|
+
path.parent.suffix.lower() == ".qda":
|
|
1013
|
+
path = path.parent
|
|
1014
|
+
return path.stem
|
|
1015
|
+
|
|
1016
|
+
def list_sessions(
|
|
1017
|
+
self,
|
|
1018
|
+
project_path: Optional[str] = None,
|
|
1019
|
+
days_old: int = 30
|
|
1020
|
+
) -> List[Dict[str, Any]]:
|
|
1021
|
+
"""List all sessions, optionally filtered by project and age.
|
|
1022
|
+
|
|
1023
|
+
Args:
|
|
1024
|
+
project_path: Filter by specific project path (optional)
|
|
1025
|
+
days_old: Only show sessions from last N days (default: 30)
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
List of session metadata dictionaries. Each entry carries the
|
|
1029
|
+
API-facing key ``coding_session_id``; the on-disk files keep
|
|
1030
|
+
their own ``session_id`` key, which ``load_session`` and
|
|
1031
|
+
``cleanup_old_sessions`` continue to read.
|
|
1032
|
+
"""
|
|
1033
|
+
sessions = []
|
|
1034
|
+
cutoff_date = datetime.now() - timedelta(days=days_old)
|
|
1035
|
+
# Compared as databases, whichever form or spelling either side
|
|
1036
|
+
# uses (same_project)
|
|
1037
|
+
wanted = self.canonical_database_path(project_path)
|
|
1038
|
+
|
|
1039
|
+
try:
|
|
1040
|
+
for filepath in self.storage_dir.glob("session_*.json"):
|
|
1041
|
+
try:
|
|
1042
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
1043
|
+
data = json.load(f)
|
|
1044
|
+
|
|
1045
|
+
# A copy whose inner id is not its file's name is
|
|
1046
|
+
# refused by every tool (load_session), so it is not
|
|
1047
|
+
# listed either: listed, it showed the real session's
|
|
1048
|
+
# id twice (fix round 2)
|
|
1049
|
+
if data.get('session_id') != \
|
|
1050
|
+
filepath.stem[len("session_"):]:
|
|
1051
|
+
logger.warning(f"Skipping session file "
|
|
1052
|
+
f"{filepath.name}: it holds another "
|
|
1053
|
+
f"session's id")
|
|
1054
|
+
continue
|
|
1055
|
+
|
|
1056
|
+
# Filter by project if specified
|
|
1057
|
+
if wanted and not self.same_project(
|
|
1058
|
+
data['project_path'], wanted):
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
# Filter by age
|
|
1062
|
+
last_modified = datetime.fromisoformat(data['last_modified'])
|
|
1063
|
+
if last_modified < cutoff_date:
|
|
1064
|
+
continue
|
|
1065
|
+
|
|
1066
|
+
# The project's folder name, not "data" (v0.14)
|
|
1067
|
+
project_name = self.project_name(data['project_path'])
|
|
1068
|
+
|
|
1069
|
+
sessions.append({
|
|
1070
|
+
# API-facing key only. The on-disk file keeps
|
|
1071
|
+
# 'session_id'; see the docstring above.
|
|
1072
|
+
'coding_session_id': data['session_id'],
|
|
1073
|
+
'created_at': data['created_at'],
|
|
1074
|
+
'last_modified': data['last_modified'],
|
|
1075
|
+
'description': data.get('description', ''),
|
|
1076
|
+
'suggestion_count': data['statistics']['total_suggestions'],
|
|
1077
|
+
'approved_count': data['statistics']['approved'],
|
|
1078
|
+
'rejected_count': data['statistics']['rejected'],
|
|
1079
|
+
'pending_count': data['statistics']['pending'],
|
|
1080
|
+
'project_name': project_name,
|
|
1081
|
+
'project_path': data['project_path']
|
|
1082
|
+
})
|
|
1083
|
+
except Exception as e:
|
|
1084
|
+
logger.warning(f"Skipping invalid session file "
|
|
1085
|
+
f"{filepath.name}: {error_label(e)}")
|
|
1086
|
+
continue
|
|
1087
|
+
|
|
1088
|
+
# Sort by last modified (most recent first)
|
|
1089
|
+
sessions.sort(key=lambda x: x['last_modified'], reverse=True)
|
|
1090
|
+
|
|
1091
|
+
except Exception as e:
|
|
1092
|
+
logger.error(f"Error listing sessions: {error_label(e)}")
|
|
1093
|
+
raise
|
|
1094
|
+
|
|
1095
|
+
return sessions
|
|
1096
|
+
|
|
1097
|
+
def delete_session(self, session_id: str) -> bool:
|
|
1098
|
+
"""Delete a session file.
|
|
1099
|
+
|
|
1100
|
+
Args:
|
|
1101
|
+
session_id: The session ID to delete
|
|
1102
|
+
|
|
1103
|
+
Returns:
|
|
1104
|
+
True if deleted, False if not found.
|
|
1105
|
+
Returns False for invalid session ID formats.
|
|
1106
|
+
"""
|
|
1107
|
+
try:
|
|
1108
|
+
self._validate_session_id(session_id)
|
|
1109
|
+
except ValueError:
|
|
1110
|
+
return False
|
|
1111
|
+
filepath = self.storage_dir / f"session_{session_id}.json"
|
|
1112
|
+
if filepath.exists():
|
|
1113
|
+
try:
|
|
1114
|
+
filepath.unlink()
|
|
1115
|
+
logger.info(f"Deleted session {session_id}")
|
|
1116
|
+
return True
|
|
1117
|
+
except Exception as e:
|
|
1118
|
+
logger.error(f"Failed to delete session {session_id}: {error_label(e)}")
|
|
1119
|
+
raise
|
|
1120
|
+
return False
|
|
1121
|
+
|
|
1122
|
+
def cleanup_old_sessions(self, days_old: int = 30) -> int:
|
|
1123
|
+
"""Delete sessions older than specified days.
|
|
1124
|
+
|
|
1125
|
+
Args:
|
|
1126
|
+
days_old: Delete sessions older than N days
|
|
1127
|
+
|
|
1128
|
+
Returns:
|
|
1129
|
+
Count of deleted sessions
|
|
1130
|
+
"""
|
|
1131
|
+
deleted = 0
|
|
1132
|
+
cutoff_date = datetime.now() - timedelta(days=days_old)
|
|
1133
|
+
|
|
1134
|
+
try:
|
|
1135
|
+
for filepath in self.storage_dir.glob("session_*.json"):
|
|
1136
|
+
try:
|
|
1137
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
1138
|
+
data = json.load(f)
|
|
1139
|
+
last_modified = datetime.fromisoformat(data['last_modified'])
|
|
1140
|
+
if last_modified < cutoff_date:
|
|
1141
|
+
filepath.unlink()
|
|
1142
|
+
deleted += 1
|
|
1143
|
+
logger.info(f"Deleted old session {data['session_id']}")
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
logger.warning(f"Error processing {filepath.name} during "
|
|
1146
|
+
f"cleanup: {error_label(e)}")
|
|
1147
|
+
continue
|
|
1148
|
+
except Exception as e:
|
|
1149
|
+
logger.error(f"Error during session cleanup: {error_label(e)}")
|
|
1150
|
+
raise
|
|
1151
|
+
|
|
1152
|
+
logger.info(f"Cleaned up {deleted} old sessions")
|
|
1153
|
+
return deleted
|