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/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