ghost-reader 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghost_reader/.release-version +1 -0
- ghost_reader/__init__.py +3 -0
- ghost_reader/agent_loader.py +64 -0
- ghost_reader/cli.py +1124 -0
- ghost_reader/constants.py +75 -0
- ghost_reader/defaults/__init__.py +1 -0
- ghost_reader/defaults/personas/__init__.py +1 -0
- ghost_reader/defaults/personas/dex.yaml +30 -0
- ghost_reader/defaults/personas/elena.yaml +30 -0
- ghost_reader/defaults/personas/mara.yaml +30 -0
- ghost_reader/defaults/personas/pip.yaml +30 -0
- ghost_reader/defaults/personas/rook.yaml +30 -0
- ghost_reader/defaults/templates/__init__.py +1 -0
- ghost_reader/defaults/templates/blog-review.html +384 -0
- ghost_reader/defaults/templates/report.html +1293 -0
- ghost_reader/dialogue.py +283 -0
- ghost_reader/errors.py +2 -0
- ghost_reader/feedback_store.py +56 -0
- ghost_reader/io.py +59 -0
- ghost_reader/models.py +227 -0
- ghost_reader/paths.py +68 -0
- ghost_reader/project.py +277 -0
- ghost_reader/release.py +56 -0
- ghost_reader/report.py +264 -0
- ghost_reader/reviews.py +89 -0
- ghost_reader/revision.py +165 -0
- ghost_reader/round.py +155 -0
- ghost_reader/server.py +282 -0
- ghost_reader/sync.py +115 -0
- ghost_reader/telemetry.py +111 -0
- ghost_reader/time.py +20 -0
- ghost_reader/validators.py +66 -0
- ghost_reader/verify.py +255 -0
- ghost_reader-0.1.0.dist-info/METADATA +221 -0
- ghost_reader-0.1.0.dist-info/RECORD +39 -0
- ghost_reader-0.1.0.dist-info/WHEEL +5 -0
- ghost_reader-0.1.0.dist-info/entry_points.txt +2 -0
- ghost_reader-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghost_reader-0.1.0.dist-info/top_level.txt +1 -0
ghost_reader/dialogue.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ghost_reader.io import load_yaml_file, write_yaml
|
|
8
|
+
from ghost_reader.paths import artifact_paths, session_dir
|
|
9
|
+
from ghost_reader.project import load_manifest, read_persona
|
|
10
|
+
from ghost_reader.reviews import feedback_summary, find_review_item, load_reviews
|
|
11
|
+
from ghost_reader.telemetry import append_event
|
|
12
|
+
from ghost_reader.time import now_iso
|
|
13
|
+
|
|
14
|
+
_TURN_HEADER = re.compile(r"^## Turn (\d+) — (.+)$")
|
|
15
|
+
|
|
16
|
+
_INDEX_SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _dialogue_dir(project_root: Path, session_id: str) -> Path:
|
|
20
|
+
return artifact_paths(project_root, session_id)["dialogue"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _index_path(project_root: Path, session_id: str) -> Path:
|
|
24
|
+
return _dialogue_dir(project_root, session_id) / "index.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load_index(project_root: Path, session_id: str) -> dict[str, Any]:
|
|
28
|
+
path = _index_path(project_root, session_id)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return {"schema_version": _INDEX_SCHEMA_VERSION, "threads": {}}
|
|
31
|
+
return load_yaml_file(path)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _save_index(project_root: Path, session_id: str, index: dict[str, Any]) -> None:
|
|
35
|
+
write_yaml(_index_path(project_root, session_id), index)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_dialogue(
|
|
39
|
+
project_root: Path, session_id: str, persona_id: str
|
|
40
|
+
) -> list[dict[str, Any]]:
|
|
41
|
+
path = _dialogue_dir(project_root, session_id) / f"{persona_id}.md"
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return []
|
|
44
|
+
turns: list[dict[str, Any]] = []
|
|
45
|
+
current_turn: dict[str, Any] | None = None
|
|
46
|
+
current_role: str | None = None
|
|
47
|
+
current_lines: list[str] = []
|
|
48
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
header_match = _TURN_HEADER.match(line)
|
|
50
|
+
if header_match:
|
|
51
|
+
if current_turn is not None:
|
|
52
|
+
_flush_turn_block(current_turn, current_role, current_lines)
|
|
53
|
+
turns.append(current_turn)
|
|
54
|
+
current_turn = {
|
|
55
|
+
"turn_index": int(header_match.group(1)),
|
|
56
|
+
"timestamp": header_match.group(2),
|
|
57
|
+
}
|
|
58
|
+
current_role = None
|
|
59
|
+
current_lines = []
|
|
60
|
+
continue
|
|
61
|
+
if line.startswith("**User:**") or line.startswith("**Persona:**"):
|
|
62
|
+
if current_role is not None and current_lines:
|
|
63
|
+
_flush_turn_block(current_turn, current_role, current_lines)
|
|
64
|
+
current_role = "user" if line.startswith("**User:**") else "persona"
|
|
65
|
+
current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
|
|
66
|
+
continue
|
|
67
|
+
if current_turn is not None and current_role is not None:
|
|
68
|
+
current_lines.append(line)
|
|
69
|
+
if current_turn is not None:
|
|
70
|
+
_flush_turn_block(current_turn, current_role, current_lines)
|
|
71
|
+
turns.append(current_turn)
|
|
72
|
+
return turns
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _flush_turn_block(
|
|
76
|
+
turn: dict[str, Any], role: str | None, lines: list[str]
|
|
77
|
+
) -> None:
|
|
78
|
+
if role and lines:
|
|
79
|
+
turn[role] = "\n".join(lines).strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def dialogue_thread_context(
|
|
83
|
+
project_root: Path,
|
|
84
|
+
session_id: str,
|
|
85
|
+
thread_id: str,
|
|
86
|
+
) -> dict[str, Any]:
|
|
87
|
+
index = _load_index(project_root, session_id)
|
|
88
|
+
threads = index.get("threads", {})
|
|
89
|
+
if thread_id not in threads:
|
|
90
|
+
raise FileNotFoundError(
|
|
91
|
+
f"Thread `{thread_id}` not found in dialogue index for session `{session_id}`."
|
|
92
|
+
)
|
|
93
|
+
return threads[thread_id]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def dialogue_context(
|
|
97
|
+
project_root: Path,
|
|
98
|
+
home: Path,
|
|
99
|
+
session_id: str,
|
|
100
|
+
persona_id: str,
|
|
101
|
+
item_id: str,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
load_manifest(project_root, session_id)
|
|
104
|
+
persona = read_persona(home, persona_id)
|
|
105
|
+
reviews = {
|
|
106
|
+
review["persona_id"]: review
|
|
107
|
+
for review in load_reviews(project_root, home, session_id)
|
|
108
|
+
}
|
|
109
|
+
review = reviews.get(persona_id)
|
|
110
|
+
if not review:
|
|
111
|
+
raise FileNotFoundError(
|
|
112
|
+
f"No review artifact found for persona `{persona_id}` in session `{session_id}`."
|
|
113
|
+
)
|
|
114
|
+
found = find_review_item(review, item_id)
|
|
115
|
+
if not found:
|
|
116
|
+
raise FileNotFoundError(
|
|
117
|
+
f"Review item `{item_id}` not found in {persona_id}'s review."
|
|
118
|
+
)
|
|
119
|
+
kind, item = found
|
|
120
|
+
feedback = feedback_summary(project_root, session_id)
|
|
121
|
+
target_id = f"{persona_id}:{item_id}"
|
|
122
|
+
user_note = feedback["user_notes"].get(target_id, "")
|
|
123
|
+
prior = load_dialogue(project_root, session_id, persona_id)
|
|
124
|
+
locators = [
|
|
125
|
+
evidence.get("locator")
|
|
126
|
+
for evidence in item.get("evidence", [])
|
|
127
|
+
if evidence.get("locator")
|
|
128
|
+
]
|
|
129
|
+
context_dir = session_dir(project_root, session_id) / "context"
|
|
130
|
+
source_map_path = context_dir / "source-map.yaml"
|
|
131
|
+
relevant_context: list[dict[str, str]] = []
|
|
132
|
+
if source_map_path.exists():
|
|
133
|
+
source_map = load_yaml_file(source_map_path)
|
|
134
|
+
for entry in source_map.get("source_ranges", []):
|
|
135
|
+
if entry.get("locator") in locators:
|
|
136
|
+
relevant_context.append(
|
|
137
|
+
{
|
|
138
|
+
"source_id": entry.get("source_id", ""),
|
|
139
|
+
"locator": entry.get("locator", ""),
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
return {
|
|
143
|
+
"session_id": session_id,
|
|
144
|
+
"persona": {
|
|
145
|
+
"id": persona["id"],
|
|
146
|
+
"reader_type": persona["reader_type"],
|
|
147
|
+
"review_focus": persona["review_focus"],
|
|
148
|
+
},
|
|
149
|
+
"review_item": {
|
|
150
|
+
"id": item_id,
|
|
151
|
+
"kind": "strength" if kind == "strengths" else "concern",
|
|
152
|
+
"reason": item.get("reason"),
|
|
153
|
+
"locator": locators[0] if locators else None,
|
|
154
|
+
"revision_hint": item.get("revision_hint"),
|
|
155
|
+
"reader_effect": item.get("reader_effect"),
|
|
156
|
+
},
|
|
157
|
+
"user_note": user_note,
|
|
158
|
+
"prior_dialogue_summary": (
|
|
159
|
+
f"{len(prior)} previous turn(s)."
|
|
160
|
+
if prior
|
|
161
|
+
else "No previous dialogue."
|
|
162
|
+
),
|
|
163
|
+
"relevant_context": relevant_context,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def dialogue_append(
|
|
168
|
+
project_root: Path,
|
|
169
|
+
home: Path,
|
|
170
|
+
session_id: str,
|
|
171
|
+
persona_id: str,
|
|
172
|
+
item_id: str,
|
|
173
|
+
turn_text: str,
|
|
174
|
+
thread_id: str | None = None,
|
|
175
|
+
round_id: int | None = None,
|
|
176
|
+
item_uid: str | None = None,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
load_manifest(project_root, session_id)
|
|
179
|
+
dialogue_dir = _dialogue_dir(project_root, session_id)
|
|
180
|
+
dialogue_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
existing = load_dialogue(project_root, session_id, persona_id)
|
|
182
|
+
turn_index = len(existing) + 1
|
|
183
|
+
timestamp = now_iso()
|
|
184
|
+
path = dialogue_dir / f"{persona_id}.md"
|
|
185
|
+
block = f"## Turn {turn_index} — {timestamp}\n\n{turn_text.strip()}\n"
|
|
186
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
187
|
+
handle.write("\n" + block if existing else block)
|
|
188
|
+
append_event(
|
|
189
|
+
home,
|
|
190
|
+
project_root,
|
|
191
|
+
"dialogue_turn_created",
|
|
192
|
+
session_id,
|
|
193
|
+
meta={
|
|
194
|
+
"persona_id": persona_id,
|
|
195
|
+
"review_item_id": item_id,
|
|
196
|
+
"turn_index": turn_index,
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
index = _load_index(project_root, session_id)
|
|
200
|
+
tid = thread_id or f"th-{persona_id}-{item_id}"
|
|
201
|
+
if tid not in index.setdefault("threads", {}):
|
|
202
|
+
index["threads"][tid] = {
|
|
203
|
+
"persona_id": persona_id,
|
|
204
|
+
"round_id": round_id or 1,
|
|
205
|
+
"item_uid": item_uid or "",
|
|
206
|
+
"created_at": timestamp,
|
|
207
|
+
"turns": [],
|
|
208
|
+
}
|
|
209
|
+
user_text, persona_text = _split_turn_text(turn_text)
|
|
210
|
+
index["threads"][tid]["turns"].append(
|
|
211
|
+
{
|
|
212
|
+
"turn_index": turn_index,
|
|
213
|
+
"timestamp": timestamp,
|
|
214
|
+
"user": user_text,
|
|
215
|
+
"persona": persona_text,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
_save_index(project_root, session_id, index)
|
|
219
|
+
return {
|
|
220
|
+
"written": True,
|
|
221
|
+
"path": str(path),
|
|
222
|
+
"turn_index": turn_index,
|
|
223
|
+
"persona_id": persona_id,
|
|
224
|
+
"review_item_id": item_id,
|
|
225
|
+
"thread_id": tid,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _split_turn_text(turn_text: str) -> tuple[str, str]:
|
|
230
|
+
user_text = ""
|
|
231
|
+
persona_text = ""
|
|
232
|
+
lines = turn_text.strip().splitlines()
|
|
233
|
+
current_role: str | None = None
|
|
234
|
+
current_lines: list[str] = []
|
|
235
|
+
for line in lines:
|
|
236
|
+
if line.startswith("**User:**"):
|
|
237
|
+
if current_role == "persona" and current_lines:
|
|
238
|
+
persona_text = "\n".join(current_lines).strip()
|
|
239
|
+
current_role = "user"
|
|
240
|
+
current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
|
|
241
|
+
elif line.startswith("**Persona:**"):
|
|
242
|
+
if current_role == "user" and current_lines:
|
|
243
|
+
user_text = "\n".join(current_lines).strip()
|
|
244
|
+
current_role = "persona"
|
|
245
|
+
current_lines = [line.split(":**", 1)[1].strip() if ":**" in line else ""]
|
|
246
|
+
elif current_role is not None:
|
|
247
|
+
current_lines.append(line)
|
|
248
|
+
if current_role == "user" and current_lines:
|
|
249
|
+
user_text = "\n".join(current_lines).strip()
|
|
250
|
+
elif current_role == "persona" and current_lines:
|
|
251
|
+
persona_text = "\n".join(current_lines).strip()
|
|
252
|
+
return user_text, persona_text
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def load_all_dialogue(
|
|
256
|
+
project_root: Path, session_id: str, personas: list[str]
|
|
257
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
258
|
+
index = _load_index(project_root, session_id)
|
|
259
|
+
if index.get("threads"):
|
|
260
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
261
|
+
for thread_id, thread in index["threads"].items():
|
|
262
|
+
persona_id = thread.get("persona_id", "")
|
|
263
|
+
if persona_id not in personas:
|
|
264
|
+
continue
|
|
265
|
+
if persona_id not in result:
|
|
266
|
+
result[persona_id] = []
|
|
267
|
+
for turn in thread.get("turns", []):
|
|
268
|
+
result[persona_id].append(
|
|
269
|
+
{
|
|
270
|
+
"thread_id": thread_id,
|
|
271
|
+
"turn_index": turn.get("turn_index"),
|
|
272
|
+
"timestamp": turn.get("timestamp"),
|
|
273
|
+
"user": turn.get("user", ""),
|
|
274
|
+
"persona": turn.get("persona", ""),
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
return result
|
|
278
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
279
|
+
for persona_id in personas:
|
|
280
|
+
turns = load_dialogue(project_root, session_id, persona_id)
|
|
281
|
+
if turns:
|
|
282
|
+
result[persona_id] = turns
|
|
283
|
+
return result
|
ghost_reader/errors.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ghost_reader.constants import SCHEMA_VERSIONS
|
|
7
|
+
from ghost_reader.io import write_yaml
|
|
8
|
+
from ghost_reader.paths import artifact_paths
|
|
9
|
+
from ghost_reader.project import load_manifest
|
|
10
|
+
from ghost_reader.telemetry import append_event
|
|
11
|
+
from ghost_reader.validators import validate_feedback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_feedback(
|
|
15
|
+
feedback: dict[str, Any],
|
|
16
|
+
session_id: str,
|
|
17
|
+
project_root: Path,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
manifest = load_manifest(project_root, session_id)
|
|
20
|
+
normalized = dict(feedback)
|
|
21
|
+
normalized["schema_version"] = SCHEMA_VERSIONS["feedback"]
|
|
22
|
+
normalized["session_id"] = session_id
|
|
23
|
+
normalized.setdefault("round_id", manifest.get("current_round", 1))
|
|
24
|
+
if "mode" not in feedback:
|
|
25
|
+
normalized["mode"] = "revise"
|
|
26
|
+
normalized.setdefault("refinement_pass", 0)
|
|
27
|
+
return normalized
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def record_feedback(
|
|
31
|
+
project_root: Path,
|
|
32
|
+
home: Path,
|
|
33
|
+
session_id: str,
|
|
34
|
+
feedback: dict[str, Any],
|
|
35
|
+
*,
|
|
36
|
+
surface: str = "cli",
|
|
37
|
+
) -> Path:
|
|
38
|
+
"""Validate, persist feedback.yaml, and append content-safe telemetry."""
|
|
39
|
+
load_manifest(project_root, session_id)
|
|
40
|
+
feedback = normalize_feedback(feedback, session_id, project_root)
|
|
41
|
+
validate_feedback(feedback, session_id)
|
|
42
|
+
target = artifact_paths(project_root, session_id)["feedback"] / "feedback.yaml"
|
|
43
|
+
write_yaml(target, feedback)
|
|
44
|
+
append_event(
|
|
45
|
+
home,
|
|
46
|
+
project_root,
|
|
47
|
+
"feedback_recorded",
|
|
48
|
+
session_id,
|
|
49
|
+
surface=surface,
|
|
50
|
+
meta={
|
|
51
|
+
"selected_review_item_ids": [
|
|
52
|
+
item["target_id"] for item in feedback.get("selected_items", [])
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
return target
|
ghost_reader/io.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from ghost_reader.errors import GhostReaderError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_yaml_file(path: Path) -> Any:
|
|
14
|
+
try:
|
|
15
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
16
|
+
return yaml.safe_load(handle) or {}
|
|
17
|
+
except yaml.YAMLError as exc:
|
|
18
|
+
raise GhostReaderError(_yaml_error_message(str(path), exc)) from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_yaml_text(text: str, source: str = "provided YAML text") -> Any:
|
|
22
|
+
try:
|
|
23
|
+
return yaml.safe_load(text) or {}
|
|
24
|
+
except yaml.YAMLError as exc:
|
|
25
|
+
raise GhostReaderError(_yaml_error_message(source, exc)) from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _yaml_error_message(source: str, exc: yaml.YAMLError) -> str:
|
|
29
|
+
return (
|
|
30
|
+
f"Invalid YAML in {source}. Common causes are unquoted special characters "
|
|
31
|
+
f"(:, #, {{, }}, [, ]) or inconsistent indentation. Original parser message: {exc}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def dump_yaml(data: Any) -> str:
|
|
36
|
+
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_yaml(path: Path, data: Any) -> None:
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
path.write_text(dump_yaml(data), encoding="utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def emit_yaml(data: Any) -> None:
|
|
45
|
+
sys.stdout.write(dump_yaml(data))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_input(args: argparse.Namespace) -> str:
|
|
49
|
+
if getattr(args, "from_file", None):
|
|
50
|
+
if args.from_file == "-":
|
|
51
|
+
return sys.stdin.read()
|
|
52
|
+
return Path(args.from_file).read_text(encoding="utf-8")
|
|
53
|
+
if getattr(args, "content", None) is not None:
|
|
54
|
+
return args.content
|
|
55
|
+
if sys.stdin.isatty():
|
|
56
|
+
raise GhostReaderError(
|
|
57
|
+
"No input provided. Use --from, --content, or pipe content on stdin."
|
|
58
|
+
)
|
|
59
|
+
return sys.stdin.read()
|
ghost_reader/models.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Pydantic models for ghost-reader data schemas.
|
|
2
|
+
|
|
3
|
+
Provides parse-once validation for persona, review, and feedback data.
|
|
4
|
+
All models preserve schema_version for backward compatibility with on-disk files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
from pydantic.config import ConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GhostReaderBase(BaseModel):
|
|
14
|
+
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PersonaProfile(GhostReaderBase):
|
|
18
|
+
summary: str | None = None
|
|
19
|
+
favorite_genres: list[str] = Field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PersonaPreferences(GhostReaderBase):
|
|
23
|
+
seeks: list[str] = Field(default_factory=list)
|
|
24
|
+
dislikes: list[str] = Field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ReviewFocus(GhostReaderBase):
|
|
28
|
+
primary: list[str] = Field(default_factory=list)
|
|
29
|
+
secondary: list[str] = Field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FeedbackVoice(GhostReaderBase):
|
|
33
|
+
voice: str | None = None
|
|
34
|
+
default_question: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Persona(GhostReaderBase):
|
|
38
|
+
"""Persona schema v1 — loaded from YAML files in defaults/personas/."""
|
|
39
|
+
|
|
40
|
+
schema_version: int = 1
|
|
41
|
+
id: str
|
|
42
|
+
name: str
|
|
43
|
+
reader_type: str
|
|
44
|
+
profile: PersonaProfile = PersonaProfile()
|
|
45
|
+
preferences: PersonaPreferences = PersonaPreferences()
|
|
46
|
+
review_focus: ReviewFocus = ReviewFocus()
|
|
47
|
+
feedback_style: FeedbackVoice = FeedbackVoice()
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def check_schema_version(self) -> Persona:
|
|
51
|
+
from ghost_reader.constants import SCHEMA_VERSIONS
|
|
52
|
+
|
|
53
|
+
expected = SCHEMA_VERSIONS["persona"]
|
|
54
|
+
if self.schema_version != expected:
|
|
55
|
+
raise ValueError(f"Persona schema_version must be {expected}.")
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
@model_validator(mode="after")
|
|
59
|
+
def check_forbidden_fields(self) -> Persona:
|
|
60
|
+
forbidden = {
|
|
61
|
+
"posture",
|
|
62
|
+
"tolerance",
|
|
63
|
+
"telemetry",
|
|
64
|
+
"matching",
|
|
65
|
+
"analytics",
|
|
66
|
+
"weights",
|
|
67
|
+
}
|
|
68
|
+
# Check both defined fields and extra fields (extra="allow" stores unknown keys)
|
|
69
|
+
defined = set(type(self).model_fields.keys())
|
|
70
|
+
extra = getattr(self, "model_extra", {}) or {}
|
|
71
|
+
present = sorted(forbidden.intersection(defined | set(extra.keys())))
|
|
72
|
+
if present:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Persona includes forbidden Phase 1 fields: {', '.join(present)}"
|
|
75
|
+
)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Evidence(GhostReaderBase):
|
|
80
|
+
source_id: str
|
|
81
|
+
locator: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ReviewItem(GhostReaderBase):
|
|
85
|
+
id: str = Field(..., pattern=r"^[sc][1-9][0-9]*$")
|
|
86
|
+
reason: str
|
|
87
|
+
item_uid: str = Field(
|
|
88
|
+
...,
|
|
89
|
+
pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
90
|
+
)
|
|
91
|
+
refinement_pass: bool
|
|
92
|
+
evidence: list[Evidence] = Field(..., min_length=1)
|
|
93
|
+
|
|
94
|
+
# concern-specific (optional for strengths)
|
|
95
|
+
reader_effect: str | None = None
|
|
96
|
+
revision_hint: str | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class OverallScore(GhostReaderBase):
|
|
100
|
+
summary: str
|
|
101
|
+
would_continue: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ReviewScores(GhostReaderBase):
|
|
105
|
+
engagement: int = Field(..., ge=1, le=10)
|
|
106
|
+
character: int = Field(..., ge=1, le=10)
|
|
107
|
+
pacing: int = Field(..., ge=1, le=10)
|
|
108
|
+
genre_fit: int = Field(..., ge=1, le=10)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Review(GhostReaderBase):
|
|
112
|
+
"""Review schema v2 — loaded from .review.yaml artifact files."""
|
|
113
|
+
|
|
114
|
+
schema_version: int = 2
|
|
115
|
+
session_id: str
|
|
116
|
+
persona_id: str
|
|
117
|
+
round_id: int | str
|
|
118
|
+
overall: OverallScore
|
|
119
|
+
scores: ReviewScores
|
|
120
|
+
strengths: list[ReviewItem]
|
|
121
|
+
concerns: list[ReviewItem]
|
|
122
|
+
|
|
123
|
+
@model_validator(mode="after")
|
|
124
|
+
def check_schema_version(self) -> Review:
|
|
125
|
+
from ghost_reader.constants import SCHEMA_VERSIONS
|
|
126
|
+
|
|
127
|
+
expected = SCHEMA_VERSIONS["review"]
|
|
128
|
+
if self.schema_version != expected:
|
|
129
|
+
raise ValueError(f"Review schema_version must be {expected}.")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
@model_validator(mode="after")
|
|
133
|
+
def check_strength_ids(self) -> Review:
|
|
134
|
+
for item in self.strengths:
|
|
135
|
+
if not item.id.startswith("s"):
|
|
136
|
+
raise ValueError(f"Strength id `{item.id}` must start with s.")
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
@model_validator(mode="after")
|
|
140
|
+
def check_concern_ids(self) -> Review:
|
|
141
|
+
for item in self.concerns:
|
|
142
|
+
if not item.id.startswith("c"):
|
|
143
|
+
raise ValueError(f"Concern id `{item.id}` must start with c.")
|
|
144
|
+
if not item.reader_effect:
|
|
145
|
+
raise ValueError(f"Concern {item.id} must include reader_effect.")
|
|
146
|
+
if not item.revision_hint:
|
|
147
|
+
raise ValueError(f"Concern {item.id} must include revision_hint.")
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SelectedItem(GhostReaderBase):
|
|
152
|
+
target_id: str
|
|
153
|
+
item_uid: str
|
|
154
|
+
thread_id: str
|
|
155
|
+
note: str | None = None
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def check_forbidden_fields(self) -> SelectedItem:
|
|
159
|
+
forbidden = {
|
|
160
|
+
"supportive_items",
|
|
161
|
+
"rejective_items",
|
|
162
|
+
"accepted_items",
|
|
163
|
+
"rejected_items",
|
|
164
|
+
"stance",
|
|
165
|
+
"classification",
|
|
166
|
+
}
|
|
167
|
+
defined = set(type(self).model_fields.keys())
|
|
168
|
+
extra = getattr(self, "model_extra", {}) or {}
|
|
169
|
+
present = sorted(forbidden.intersection(defined | set(extra.keys())))
|
|
170
|
+
if present:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"SelectedItem includes forbidden fields: {', '.join(present)}"
|
|
173
|
+
)
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class Feedback(GhostReaderBase):
|
|
178
|
+
"""Feedback schema v2 — loaded from feedback.yaml artifact files."""
|
|
179
|
+
|
|
180
|
+
schema_version: int = 2
|
|
181
|
+
session_id: str
|
|
182
|
+
round_id: int | str | None = None
|
|
183
|
+
mode: str
|
|
184
|
+
selected_items: list[SelectedItem] = Field(..., min_length=1)
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def check_schema_version(self) -> Feedback:
|
|
188
|
+
from ghost_reader.constants import SCHEMA_VERSIONS
|
|
189
|
+
|
|
190
|
+
expected = SCHEMA_VERSIONS["feedback"]
|
|
191
|
+
if self.schema_version != expected:
|
|
192
|
+
raise ValueError(f"Feedback schema_version must be {expected}.")
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
@model_validator(mode="after")
|
|
196
|
+
def check_mode(self) -> Feedback:
|
|
197
|
+
if self.mode not in ("refine", "revise"):
|
|
198
|
+
raise ValueError("Feedback mode must be 'refine' or 'revise'.")
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
@model_validator(mode="after")
|
|
202
|
+
def check_target_id_format(self) -> Feedback:
|
|
203
|
+
for item in self.selected_items:
|
|
204
|
+
if ":" not in item.target_id:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"Feedback target_id `{item.target_id}` must use persona:item format."
|
|
207
|
+
)
|
|
208
|
+
return self
|
|
209
|
+
|
|
210
|
+
@model_validator(mode="after")
|
|
211
|
+
def check_forbidden_fields(self) -> Feedback:
|
|
212
|
+
forbidden = {
|
|
213
|
+
"supportive_items",
|
|
214
|
+
"rejective_items",
|
|
215
|
+
"accepted_items",
|
|
216
|
+
"rejected_items",
|
|
217
|
+
"stance",
|
|
218
|
+
"classification",
|
|
219
|
+
}
|
|
220
|
+
defined = set(type(self).model_fields.keys())
|
|
221
|
+
extra = getattr(self, "model_extra", {}) or {}
|
|
222
|
+
present = sorted(forbidden.intersection(defined | set(extra.keys())))
|
|
223
|
+
if present:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Feedback includes forbidden stance-classification fields: {', '.join(present)}"
|
|
226
|
+
)
|
|
227
|
+
return self
|
ghost_reader/paths.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ghost_reader.errors import GhostReaderError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ghost_reader_home() -> Path:
|
|
10
|
+
return Path(
|
|
11
|
+
os.environ.get("GHOST_READER_HOME", Path.home() / ".ghostreader")
|
|
12
|
+
).expanduser()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def project_dir(project_root: Path) -> Path:
|
|
16
|
+
return project_root / ".ghostreader"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def session_dir(project_root: Path, session_id: str) -> Path:
|
|
20
|
+
return project_dir(project_root) / "sessions" / session_id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def round_dir(project_root: Path, session_id: str, round_id: int) -> Path:
|
|
24
|
+
return session_dir(project_root, session_id) / f"round-{round_id}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def active_round_dir(project_root: Path, session_id: str) -> Path:
|
|
28
|
+
from ghost_reader.project import load_manifest
|
|
29
|
+
|
|
30
|
+
manifest = load_manifest(project_root, session_id)
|
|
31
|
+
current = manifest.get("current_round", 1)
|
|
32
|
+
return round_dir(project_root, session_id, current)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def artifact_paths(project_root: Path, session_id: str) -> dict[str, Path]:
|
|
36
|
+
active = active_round_dir(project_root, session_id)
|
|
37
|
+
session = session_dir(project_root, session_id)
|
|
38
|
+
return {
|
|
39
|
+
"context": active / "context",
|
|
40
|
+
"reviews": active / "reviews",
|
|
41
|
+
"feedback": active / "feedback",
|
|
42
|
+
"prompts": active / "prompts",
|
|
43
|
+
"report": active / "report",
|
|
44
|
+
"refinements": active / "refinements",
|
|
45
|
+
"dialogue": session / "dialogue",
|
|
46
|
+
"manifest": session / "manifest.yaml",
|
|
47
|
+
"telemetry": session / "telemetry",
|
|
48
|
+
"session": session,
|
|
49
|
+
"active_round": active,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_project_root(start: Path | None = None) -> Path:
|
|
54
|
+
current = (start or Path.cwd()).resolve()
|
|
55
|
+
for candidate in [current, *current.parents]:
|
|
56
|
+
if (candidate / ".ghostreader" / "project.yaml").exists():
|
|
57
|
+
return candidate
|
|
58
|
+
raise GhostReaderError(
|
|
59
|
+
"Ghost Reader project not initialized. Run `ghost-reader init` first."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def outbox_path(home: Path) -> Path:
|
|
64
|
+
return home / "telemetry" / "outbox" / "events.ndjson"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sync_state_path(home: Path) -> Path:
|
|
68
|
+
return home / "telemetry" / "sync-state.yaml"
|