ghost-reader 0.0.1__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 +281 -0
- ghost_reader/sync.py +112 -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.0.1.dist-info/METADATA +221 -0
- ghost_reader-0.0.1.dist-info/RECORD +39 -0
- ghost_reader-0.0.1.dist-info/WHEEL +5 -0
- ghost_reader-0.0.1.dist-info/entry_points.txt +2 -0
- ghost_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
- ghost_reader-0.0.1.dist-info/top_level.txt +1 -0
ghost_reader/project.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ghost_reader.agent_loader import load_agent_yaml
|
|
9
|
+
from ghost_reader.constants import AVAILABLE_PERSONAS, DEFAULT_PERSONAS, ROUND_DIRECTORIES
|
|
10
|
+
from ghost_reader.errors import GhostReaderError
|
|
11
|
+
from ghost_reader.io import load_yaml_file, write_yaml
|
|
12
|
+
from ghost_reader.paths import project_dir, round_dir, session_dir
|
|
13
|
+
from ghost_reader.time import now_iso
|
|
14
|
+
from ghost_reader.validators import validate_persona
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_home(home: Path) -> None:
|
|
18
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
(home / "personas").mkdir(parents=True, exist_ok=True)
|
|
20
|
+
(home / "templates").mkdir(parents=True, exist_ok=True)
|
|
21
|
+
for directory in ["outbox", "synced", "receipts"]:
|
|
22
|
+
(home / "telemetry" / directory).mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
config_path = home / "config.yaml"
|
|
25
|
+
if not config_path.exists():
|
|
26
|
+
write_yaml(
|
|
27
|
+
config_path,
|
|
28
|
+
{
|
|
29
|
+
"schema_version": 1,
|
|
30
|
+
"install_id": str(uuid.uuid4()),
|
|
31
|
+
"created_at": now_iso(),
|
|
32
|
+
"telemetry": {
|
|
33
|
+
"auto_sync": False,
|
|
34
|
+
"sync_endpoint": None,
|
|
35
|
+
"include_private_notes": False,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
persona_resources = resources.files("ghost_reader.defaults.personas")
|
|
41
|
+
for persona_id in AVAILABLE_PERSONAS:
|
|
42
|
+
target = home / "personas" / f"{persona_id}.yaml"
|
|
43
|
+
if not target.exists():
|
|
44
|
+
source = persona_resources / f"{persona_id}.yaml"
|
|
45
|
+
target.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
template_resources = resources.files("ghost_reader.defaults.templates")
|
|
48
|
+
for template_path in template_resources.iterdir():
|
|
49
|
+
if template_path.suffix == ".html":
|
|
50
|
+
target = home / "templates" / template_path.name
|
|
51
|
+
target.write_text(template_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_project(project_root: Path, home: Path) -> dict[str, Any]:
|
|
55
|
+
ensure_home(home)
|
|
56
|
+
root = project_dir(project_root)
|
|
57
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
project_path = root / "project.yaml"
|
|
59
|
+
if not project_path.exists():
|
|
60
|
+
write_yaml(
|
|
61
|
+
project_path,
|
|
62
|
+
{
|
|
63
|
+
"schema_version": 1,
|
|
64
|
+
"project_id": project_root.name,
|
|
65
|
+
"created_at": now_iso(),
|
|
66
|
+
"default_personas": DEFAULT_PERSONAS,
|
|
67
|
+
"available_personas": AVAILABLE_PERSONAS,
|
|
68
|
+
"persona_selection": {
|
|
69
|
+
"count": 3,
|
|
70
|
+
"default_personas": DEFAULT_PERSONAS,
|
|
71
|
+
"available_personas": AVAILABLE_PERSONAS,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
return load_yaml_file(project_path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def read_config(home: Path) -> dict[str, Any]:
|
|
79
|
+
ensure_home(home)
|
|
80
|
+
return load_yaml_file(home / "config.yaml")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def persona_brief(persona: dict[str, Any]) -> str:
|
|
84
|
+
focus = persona.get("review_focus", {})
|
|
85
|
+
primary = focus.get("primary")
|
|
86
|
+
if isinstance(primary, list) and primary:
|
|
87
|
+
return ", ".join(str(item) for item in primary)
|
|
88
|
+
reader_type = persona.get("reader_type")
|
|
89
|
+
return str(reader_type) if reader_type else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_persona(home: Path, persona_id: str) -> dict[str, Any]:
|
|
93
|
+
agent = load_agent_yaml(home, persona_id)
|
|
94
|
+
if agent is not None:
|
|
95
|
+
persona = _persona_from_agent(agent, persona_id)
|
|
96
|
+
validate_persona(persona)
|
|
97
|
+
return persona
|
|
98
|
+
|
|
99
|
+
persona_path = home / "personas" / f"{persona_id}.yaml"
|
|
100
|
+
if persona_path.exists():
|
|
101
|
+
persona = load_yaml_file(persona_path)
|
|
102
|
+
validate_persona(persona)
|
|
103
|
+
return persona
|
|
104
|
+
|
|
105
|
+
for agent_home in _fallback_agent_homes(home):
|
|
106
|
+
agent = load_agent_yaml(agent_home, persona_id)
|
|
107
|
+
if agent is not None:
|
|
108
|
+
persona = _persona_from_agent(agent, persona_id)
|
|
109
|
+
validate_persona(persona)
|
|
110
|
+
return persona
|
|
111
|
+
|
|
112
|
+
raise GhostReaderError(
|
|
113
|
+
f"Unknown persona `{persona_id}`. Run `ghost-reader init` to install defaults."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fallback_agent_homes(home: Path) -> list[Path]:
|
|
118
|
+
candidates = [Path(__file__).resolve().parents[2], Path.cwd()]
|
|
119
|
+
unique: list[Path] = []
|
|
120
|
+
home_resolved = home.resolve()
|
|
121
|
+
for candidate in candidates:
|
|
122
|
+
resolved = candidate.resolve()
|
|
123
|
+
if resolved != home_resolved and resolved not in unique:
|
|
124
|
+
unique.append(resolved)
|
|
125
|
+
return unique
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _persona_from_agent(agent: dict[str, Any], persona_id: str) -> dict[str, Any]:
|
|
129
|
+
metadata = agent.get("metadata", {})
|
|
130
|
+
if not isinstance(metadata, dict):
|
|
131
|
+
metadata = {}
|
|
132
|
+
system = str(agent.get("system") or "")
|
|
133
|
+
return {
|
|
134
|
+
"schema_version": int(metadata.get("persona_schema_version", 1)),
|
|
135
|
+
"id": persona_id,
|
|
136
|
+
"name": _agent_display_name(agent, persona_id, system),
|
|
137
|
+
"reader_type": _extract_prefixed_value(system, "- Reader type:")
|
|
138
|
+
or str(agent.get("description") or persona_id),
|
|
139
|
+
"profile": {
|
|
140
|
+
"summary": _extract_prefixed_value(system, "- Summary:"),
|
|
141
|
+
"favorite_genres": _split_agent_list(
|
|
142
|
+
_extract_prefixed_value(system, "- Favorite genres:")
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
"preferences": {
|
|
146
|
+
"seeks": _extract_section_list(system, "What you seek:"),
|
|
147
|
+
"dislikes": _extract_section_list(system, "What you dislike:"),
|
|
148
|
+
},
|
|
149
|
+
"review_focus": {
|
|
150
|
+
"primary": _split_agent_list(
|
|
151
|
+
_extract_prefixed_value(system, "- Primary:")
|
|
152
|
+
),
|
|
153
|
+
"secondary": _split_agent_list(
|
|
154
|
+
_extract_prefixed_value(system, "- Secondary:")
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
"feedback_style": {
|
|
158
|
+
"voice": _extract_prefixed_value(system, "- Voice:"),
|
|
159
|
+
"default_question": _extract_prefixed_value(
|
|
160
|
+
system, "- Default guiding question:"
|
|
161
|
+
),
|
|
162
|
+
},
|
|
163
|
+
"agent": {
|
|
164
|
+
"name": agent.get("name"),
|
|
165
|
+
"model": agent.get("model"),
|
|
166
|
+
"description": agent.get("description"),
|
|
167
|
+
"system": agent.get("system"),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _agent_display_name(agent: dict[str, Any], persona_id: str, system: str) -> str:
|
|
173
|
+
first_line = system.splitlines()[0] if system else ""
|
|
174
|
+
prefix = "You are "
|
|
175
|
+
if first_line.startswith(prefix) and "," in first_line:
|
|
176
|
+
return first_line[len(prefix) : first_line.index(",")].strip()
|
|
177
|
+
name = agent.get("name")
|
|
178
|
+
if isinstance(name, str) and name:
|
|
179
|
+
return name.split("-", 1)[0].title()
|
|
180
|
+
return persona_id.title()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract_prefixed_value(text: str, prefix: str) -> str | None:
|
|
184
|
+
for line in text.splitlines():
|
|
185
|
+
stripped = line.strip()
|
|
186
|
+
if stripped.startswith(prefix):
|
|
187
|
+
return stripped[len(prefix) :].strip().rstrip(".")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _split_agent_list(value: str | None) -> list[str]:
|
|
192
|
+
if not value:
|
|
193
|
+
return []
|
|
194
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _extract_section_list(text: str, heading: str) -> list[str]:
|
|
198
|
+
items: list[str] = []
|
|
199
|
+
in_section = False
|
|
200
|
+
for line in text.splitlines():
|
|
201
|
+
stripped = line.strip()
|
|
202
|
+
if stripped == heading:
|
|
203
|
+
in_section = True
|
|
204
|
+
continue
|
|
205
|
+
if in_section:
|
|
206
|
+
if not stripped:
|
|
207
|
+
break
|
|
208
|
+
if stripped.startswith("- "):
|
|
209
|
+
items.append(stripped[2:].strip().rstrip("."))
|
|
210
|
+
return items
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def ensure_session_structure(session_path: Path) -> None:
|
|
214
|
+
dialogue_dir = session_path / "dialogue"
|
|
215
|
+
dialogue_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
(session_path / "telemetry").mkdir(parents=True, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
round_1 = session_path / "round-1"
|
|
219
|
+
round_1.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
for child in ROUND_DIRECTORIES:
|
|
221
|
+
(round_1 / child).mkdir(parents=True, exist_ok=True)
|
|
222
|
+
|
|
223
|
+
if not (round_1 / "manifest.yaml").exists():
|
|
224
|
+
write_yaml(
|
|
225
|
+
round_1 / "manifest.yaml",
|
|
226
|
+
{
|
|
227
|
+
"round_id": 1,
|
|
228
|
+
"schema_version": 1,
|
|
229
|
+
"previous_round": None,
|
|
230
|
+
"status": "reviews_pending",
|
|
231
|
+
"refinement_count": 0,
|
|
232
|
+
"created_at": now_iso(),
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_manifest(project_root: Path, session_id: str) -> dict[str, Any]:
|
|
238
|
+
path = session_dir(project_root, session_id) / "manifest.yaml"
|
|
239
|
+
if not path.exists():
|
|
240
|
+
raise GhostReaderError(f"Session `{session_id}` not found.")
|
|
241
|
+
return load_yaml_file(path)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def save_manifest(
|
|
245
|
+
project_root: Path, session_id: str, manifest: dict[str, Any]
|
|
246
|
+
) -> None:
|
|
247
|
+
manifest["updated_at"] = now_iso()
|
|
248
|
+
write_yaml(session_dir(project_root, session_id) / "manifest.yaml", manifest)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_round_manifest(
|
|
252
|
+
project_root: Path, session_id: str, round_id: int
|
|
253
|
+
) -> dict[str, Any]:
|
|
254
|
+
path = round_dir(project_root, session_id, round_id) / "manifest.yaml"
|
|
255
|
+
if not path.exists():
|
|
256
|
+
raise GhostReaderError(f"Round `{round_id}` not found in session `{session_id}`.")
|
|
257
|
+
return load_yaml_file(path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def save_round_manifest(
|
|
261
|
+
project_root: Path, session_id: str, round_id: int, manifest: dict[str, Any]
|
|
262
|
+
) -> None:
|
|
263
|
+
write_yaml(
|
|
264
|
+
round_dir(project_root, session_id, round_id) / "manifest.yaml", manifest
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_personas(value: str | None) -> list[str]:
|
|
269
|
+
if not value:
|
|
270
|
+
return DEFAULT_PERSONAS
|
|
271
|
+
personas = [
|
|
272
|
+
part.strip() for part in value.replace(",", " ").split() if part.strip()
|
|
273
|
+
]
|
|
274
|
+
unknown = [persona for persona in personas if persona not in AVAILABLE_PERSONAS]
|
|
275
|
+
if unknown:
|
|
276
|
+
raise GhostReaderError(f"Unknown personas: {', '.join(unknown)}")
|
|
277
|
+
return personas
|
ghost_reader/release.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
GITHUB_REPO = "clchinkc/ghost-reader"
|
|
11
|
+
GITHUB_REPO_URL = f"https://github.com/{GITHUB_REPO}"
|
|
12
|
+
# Private repo: HTTPS install uses your git credentials; SSH URL is the reliable default.
|
|
13
|
+
GITHUB_INSTALL_GIT = f"git+ssh://git@github.com/{GITHUB_REPO}.git"
|
|
14
|
+
RELEASES_API = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
|
15
|
+
UNAVAILABLE_STATUS = "unavailable (no network and no local release record)"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def embedded_release_version() -> str | None:
|
|
19
|
+
"""Return the packaged release marker, if this install includes one."""
|
|
20
|
+
try:
|
|
21
|
+
text = resources.files("ghost_reader").joinpath(".release-version").read_text(
|
|
22
|
+
encoding="utf-8"
|
|
23
|
+
)
|
|
24
|
+
except (FileNotFoundError, ModuleNotFoundError):
|
|
25
|
+
return None
|
|
26
|
+
version = text.strip().lstrip("v")
|
|
27
|
+
return version or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fetch_latest_release() -> dict[str, Any] | None:
|
|
31
|
+
"""Return latest GitHub release metadata, or None if unavailable."""
|
|
32
|
+
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
|
33
|
+
headers = {
|
|
34
|
+
"Accept": "application/vnd.github+json",
|
|
35
|
+
"User-Agent": "ghost-reader-cli",
|
|
36
|
+
}
|
|
37
|
+
if token:
|
|
38
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
39
|
+
|
|
40
|
+
request = urllib.request.Request(
|
|
41
|
+
RELEASES_API,
|
|
42
|
+
headers=headers,
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(request, timeout=8) as response:
|
|
46
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
47
|
+
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError):
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
tag = str(payload.get("tag_name", "")).lstrip("v")
|
|
51
|
+
return {
|
|
52
|
+
"tag": payload.get("tag_name"),
|
|
53
|
+
"version": tag,
|
|
54
|
+
"published_at": payload.get("published_at"),
|
|
55
|
+
"html_url": payload.get("html_url"),
|
|
56
|
+
}
|
ghost_reader/report.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from ghost_reader.constants import TEMPLATE_VERSION
|
|
11
|
+
from ghost_reader.io import load_yaml_file
|
|
12
|
+
from ghost_reader.paths import artifact_paths
|
|
13
|
+
from ghost_reader.project import ensure_home, load_manifest
|
|
14
|
+
from ghost_reader.reviews import feedback_summary, load_reviews
|
|
15
|
+
from ghost_reader.telemetry import append_event
|
|
16
|
+
from ghost_reader.time import now_iso
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ranges_overlap(start_a: int, end_a: int, start_b: int, end_b: int) -> bool:
|
|
20
|
+
return start_a <= end_b and start_b <= end_a
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_source_ranges(context_dir: Path) -> list[dict[str, object]]:
|
|
24
|
+
source_map_path = context_dir / "source-map.yaml"
|
|
25
|
+
if not source_map_path.exists():
|
|
26
|
+
return []
|
|
27
|
+
source_map = load_yaml_file(source_map_path)
|
|
28
|
+
normalized: list[dict[str, object]] = []
|
|
29
|
+
for item in source_map.get("source_ranges", []):
|
|
30
|
+
entry: dict[str, object] = {}
|
|
31
|
+
for field in ("source_id", "locator", "start_line", "end_line"):
|
|
32
|
+
if field in item:
|
|
33
|
+
entry[field] = item[field]
|
|
34
|
+
if "start_line" in entry and "end_line" in entry and "locator" in entry:
|
|
35
|
+
normalized.append(entry)
|
|
36
|
+
return normalized
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def source_ranges_for_lines(
|
|
40
|
+
source_ranges: list[dict[str, object]], line_start: int, line_end: int
|
|
41
|
+
) -> list[dict[str, object]]:
|
|
42
|
+
matching_ranges = []
|
|
43
|
+
for item in source_ranges:
|
|
44
|
+
if not item.get("start_line") or not item.get("end_line"):
|
|
45
|
+
continue
|
|
46
|
+
if ranges_overlap(
|
|
47
|
+
line_start,
|
|
48
|
+
line_end,
|
|
49
|
+
int(item["start_line"]),
|
|
50
|
+
int(item["end_line"]),
|
|
51
|
+
):
|
|
52
|
+
matching_ranges.append(item)
|
|
53
|
+
return matching_ranges
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def source_paragraph_payload(
|
|
57
|
+
paragraph_id: int,
|
|
58
|
+
text: str,
|
|
59
|
+
line_start: int,
|
|
60
|
+
line_end: int,
|
|
61
|
+
source_ranges: list[dict[str, object]],
|
|
62
|
+
) -> dict[str, object]:
|
|
63
|
+
matching_ranges = source_ranges_for_lines(source_ranges, line_start, line_end)
|
|
64
|
+
locators = [
|
|
65
|
+
item["locator"] for item in matching_ranges if item.get("locator")
|
|
66
|
+
]
|
|
67
|
+
if not locators:
|
|
68
|
+
locators = [f"p{paragraph_id}"]
|
|
69
|
+
return {
|
|
70
|
+
"id": paragraph_id,
|
|
71
|
+
"line_start": line_start,
|
|
72
|
+
"line_end": line_end,
|
|
73
|
+
"source_ids": [
|
|
74
|
+
item["source_id"] for item in matching_ranges if item.get("source_id")
|
|
75
|
+
],
|
|
76
|
+
"locators": locators,
|
|
77
|
+
"text": text,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def source_text_paragraphs(
|
|
82
|
+
text: str, source_ranges: list[dict[str, object]]
|
|
83
|
+
) -> list[dict[str, object]]:
|
|
84
|
+
paragraphs: list[dict[str, object]] = []
|
|
85
|
+
current_lines: list[str] = []
|
|
86
|
+
line_start: int | None = None
|
|
87
|
+
|
|
88
|
+
def flush(line_end: int) -> None:
|
|
89
|
+
nonlocal current_lines, line_start
|
|
90
|
+
if line_start is None or not current_lines:
|
|
91
|
+
return
|
|
92
|
+
paragraph_text = "\n".join(current_lines).strip()
|
|
93
|
+
if paragraph_text and not paragraph_text.startswith("#"):
|
|
94
|
+
paragraphs.append(
|
|
95
|
+
source_paragraph_payload(
|
|
96
|
+
len(paragraphs) + 1,
|
|
97
|
+
paragraph_text,
|
|
98
|
+
line_start,
|
|
99
|
+
line_end,
|
|
100
|
+
source_ranges,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
current_lines = []
|
|
104
|
+
line_start = None
|
|
105
|
+
|
|
106
|
+
lines = text.splitlines()
|
|
107
|
+
for line_number, line in enumerate(lines, start=1):
|
|
108
|
+
if line.strip():
|
|
109
|
+
if line_start is None:
|
|
110
|
+
line_start = line_number
|
|
111
|
+
current_lines.append(line)
|
|
112
|
+
else:
|
|
113
|
+
flush(line_number - 1)
|
|
114
|
+
flush(len(lines))
|
|
115
|
+
return paragraphs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_source_text(project_root: Path, session_id: str) -> dict[str, object]:
|
|
119
|
+
paths = artifact_paths(project_root, session_id)
|
|
120
|
+
source_path = paths["context"] / "source-text.md"
|
|
121
|
+
if not source_path.exists():
|
|
122
|
+
return {"found": False, "paragraphs": []}
|
|
123
|
+
paragraphs = source_text_paragraphs(
|
|
124
|
+
source_path.read_text(encoding="utf-8"),
|
|
125
|
+
load_source_ranges(paths["context"]),
|
|
126
|
+
)
|
|
127
|
+
return {
|
|
128
|
+
"found": bool(paragraphs),
|
|
129
|
+
"path": str(source_path),
|
|
130
|
+
"paragraphs": paragraphs,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_revision_prompt(project_root: Path, session_id: str) -> dict[str, str | bool]:
|
|
135
|
+
paths = artifact_paths(project_root, session_id)
|
|
136
|
+
yaml_path = paths["prompts"] / "revision-prompt.yaml"
|
|
137
|
+
markdown_path = paths["prompts"] / "revision-prompt.md"
|
|
138
|
+
if not markdown_path.exists():
|
|
139
|
+
return {"found": False}
|
|
140
|
+
return {
|
|
141
|
+
"found": True,
|
|
142
|
+
"yaml": str(yaml_path) if yaml_path.exists() else "",
|
|
143
|
+
"markdown": str(markdown_path),
|
|
144
|
+
"text": markdown_path.read_text(encoding="utf-8"),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_dialogue_threads(project_root: Path, session_id: str) -> dict[str, object]:
|
|
149
|
+
paths = artifact_paths(project_root, session_id)
|
|
150
|
+
index_path = paths["dialogue"] / "index.yaml"
|
|
151
|
+
if not index_path.exists():
|
|
152
|
+
return {}
|
|
153
|
+
index = load_yaml_file(index_path)
|
|
154
|
+
return index.get("threads", {})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render_report(
|
|
158
|
+
project_root: Path,
|
|
159
|
+
home: Path,
|
|
160
|
+
session_id: str,
|
|
161
|
+
command_prefix: str = "ghost-reader",
|
|
162
|
+
template_name: str = "report",
|
|
163
|
+
export_config: bool = False,
|
|
164
|
+
) -> dict[str, str]:
|
|
165
|
+
ensure_home(home)
|
|
166
|
+
manifest = load_manifest(project_root, session_id)
|
|
167
|
+
reviews = load_reviews(project_root, home, session_id)
|
|
168
|
+
paths = artifact_paths(project_root, session_id)
|
|
169
|
+
report_dir = paths["report"]
|
|
170
|
+
command = command_prefix.strip() or "ghost-reader"
|
|
171
|
+
project_root_text = str(project_root)
|
|
172
|
+
current_round = manifest.get("current_round", 1)
|
|
173
|
+
round_ids = [r["id"] for r in manifest.get("rounds", [])] or [current_round]
|
|
174
|
+
payload = {
|
|
175
|
+
"schema_version": 2,
|
|
176
|
+
"template_version": TEMPLATE_VERSION,
|
|
177
|
+
"generated_at": now_iso(),
|
|
178
|
+
"session_id": session_id,
|
|
179
|
+
"template_name": template_name,
|
|
180
|
+
"current_round": current_round,
|
|
181
|
+
"round_ids": round_ids,
|
|
182
|
+
"story_unit": manifest.get("story_unit"),
|
|
183
|
+
"personas": manifest.get("personas", []),
|
|
184
|
+
"source_text": load_source_text(project_root, session_id),
|
|
185
|
+
"reviews": reviews,
|
|
186
|
+
"dialogue_threads": load_dialogue_threads(project_root, session_id),
|
|
187
|
+
"feedback_summary": feedback_summary(project_root, session_id),
|
|
188
|
+
"revision_prompt": load_revision_prompt(project_root, session_id),
|
|
189
|
+
"commands": {
|
|
190
|
+
"project_root": project_root_text,
|
|
191
|
+
"change_directory": f"cd {shlex.quote(project_root_text)}",
|
|
192
|
+
"command_prefix": command,
|
|
193
|
+
"serve": f"{command} serve --session {session_id}",
|
|
194
|
+
"feedback_add": f"{command} feedback add --session {session_id} --from -",
|
|
195
|
+
"prompt_revision": f"{command} prompt revision --session {session_id}",
|
|
196
|
+
"render": f"{command} render --session {session_id} --format {template_name} --command-prefix {shlex.quote(command)}",
|
|
197
|
+
"verify": f"{command} verify --session {session_id}",
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
report_dir.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
payload_path = report_dir / "payload.json"
|
|
202
|
+
payload_path.write_text(
|
|
203
|
+
json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
204
|
+
)
|
|
205
|
+
template_name = os.path.basename(template_name)
|
|
206
|
+
template_path = (home / "templates" / f"{template_name}.html").resolve()
|
|
207
|
+
templates_dir = (home / "templates").resolve()
|
|
208
|
+
if not str(template_path).startswith(str(templates_dir) + os.sep) and template_path != templates_dir:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"Template path traversal detected: {template_name}"
|
|
211
|
+
)
|
|
212
|
+
if not template_path.exists():
|
|
213
|
+
raise FileNotFoundError(
|
|
214
|
+
f"Template '{template_name}.html' not found. "
|
|
215
|
+
f"Run 'ghost-reader render --session {session_id} --format list' to see available templates."
|
|
216
|
+
)
|
|
217
|
+
template = template_path.read_text(encoding="utf-8")
|
|
218
|
+
output_name = "index.html" if template_name == "report" else f"{template_name}.html"
|
|
219
|
+
html_path = report_dir / output_name
|
|
220
|
+
html_path.write_text(
|
|
221
|
+
template.replace(
|
|
222
|
+
"__GHOST_READER_PAYLOAD__", json.dumps(payload, ensure_ascii=False)
|
|
223
|
+
),
|
|
224
|
+
encoding="utf-8",
|
|
225
|
+
)
|
|
226
|
+
append_event(
|
|
227
|
+
home,
|
|
228
|
+
project_root,
|
|
229
|
+
"html_rendered",
|
|
230
|
+
session_id,
|
|
231
|
+
meta={"review_count": len(reviews), "template": template_name, "round": current_round},
|
|
232
|
+
)
|
|
233
|
+
result = {"payload": str(payload_path), "html": str(html_path)}
|
|
234
|
+
|
|
235
|
+
if export_config:
|
|
236
|
+
source_map_path = paths["context"] / "source-map.yaml"
|
|
237
|
+
source_locators: list[str] = []
|
|
238
|
+
if source_map_path.exists():
|
|
239
|
+
source_map = load_yaml_file(source_map_path)
|
|
240
|
+
source_locators = [
|
|
241
|
+
f"{item['file']}:{item['start_line']}-{item['end_line']}"
|
|
242
|
+
for item in source_map.get("source_ranges", [])
|
|
243
|
+
if item.get("file") and item.get("start_line") and item.get("end_line")
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
feedback_data = feedback_summary(project_root, session_id)
|
|
247
|
+
selected_item_ids = [item["target_id"] for item in feedback_data.get("selected_items", [])]
|
|
248
|
+
|
|
249
|
+
config = {
|
|
250
|
+
"report_version": "2.0",
|
|
251
|
+
"round": current_round,
|
|
252
|
+
"personas": manifest.get("personas", []),
|
|
253
|
+
"feedback_count": len(selected_item_ids),
|
|
254
|
+
"revision_type": "unified",
|
|
255
|
+
"source_locators": source_locators,
|
|
256
|
+
}
|
|
257
|
+
config_path = report_dir / "config.yaml"
|
|
258
|
+
config_path.write_text(
|
|
259
|
+
yaml.dump(config, default_flow_style=False, sort_keys=False),
|
|
260
|
+
encoding="utf-8",
|
|
261
|
+
)
|
|
262
|
+
result["config"] = str(config_path)
|
|
263
|
+
|
|
264
|
+
return result
|
ghost_reader/reviews.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ghost_reader.io import load_yaml_file
|
|
7
|
+
from ghost_reader.paths import artifact_paths
|
|
8
|
+
from ghost_reader.project import load_manifest, read_persona
|
|
9
|
+
from ghost_reader.validators import validate_feedback, validate_review
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def feedback_summary(project_root: Path, session_id: str) -> dict[str, Any]:
|
|
13
|
+
load_manifest(project_root, session_id)
|
|
14
|
+
path = artifact_paths(project_root, session_id)["feedback"] / "feedback.yaml"
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return {
|
|
17
|
+
"session_id": session_id,
|
|
18
|
+
"selected_items": [],
|
|
19
|
+
"user_notes": {},
|
|
20
|
+
"helpfulness_rating": None,
|
|
21
|
+
"feedback_found": False,
|
|
22
|
+
}
|
|
23
|
+
feedback = load_yaml_file(path)
|
|
24
|
+
validate_feedback(feedback, session_id)
|
|
25
|
+
selected_items = [
|
|
26
|
+
{"target_id": item["target_id"]} for item in feedback.get("selected_items", [])
|
|
27
|
+
]
|
|
28
|
+
user_notes = {
|
|
29
|
+
item["target_id"]: item.get("note", "")
|
|
30
|
+
for item in feedback.get("selected_items", [])
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
"session_id": session_id,
|
|
34
|
+
"selected_items": selected_items,
|
|
35
|
+
"user_notes": user_notes,
|
|
36
|
+
"helpfulness_rating": feedback.get("helpfulness_rating"),
|
|
37
|
+
"feedback_found": True,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_reviews(
|
|
42
|
+
project_root: Path, home: Path, session_id: str
|
|
43
|
+
) -> list[dict[str, Any]]:
|
|
44
|
+
manifest = load_manifest(project_root, session_id)
|
|
45
|
+
reviews = []
|
|
46
|
+
for review_path in sorted(
|
|
47
|
+
artifact_paths(project_root, session_id)["reviews"].glob("*.review.yaml")
|
|
48
|
+
):
|
|
49
|
+
review = load_yaml_file(review_path)
|
|
50
|
+
validate_review(review, session_id)
|
|
51
|
+
persona = read_persona(home, review["persona_id"])
|
|
52
|
+
reviews.append(
|
|
53
|
+
{
|
|
54
|
+
**review,
|
|
55
|
+
"persona_name": persona["name"],
|
|
56
|
+
"reader_type": persona["reader_type"],
|
|
57
|
+
"review_focus": persona["review_focus"],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
order = {
|
|
61
|
+
persona_id: index
|
|
62
|
+
for index, persona_id in enumerate(manifest.get("personas", []))
|
|
63
|
+
}
|
|
64
|
+
return sorted(reviews, key=lambda item: order.get(item["persona_id"], 999))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def find_review_item(
|
|
68
|
+
review: dict[str, Any], item_id: str
|
|
69
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
70
|
+
for kind in ["strengths", "concerns"]:
|
|
71
|
+
for item in review.get(kind, []):
|
|
72
|
+
if item.get("id") == item_id:
|
|
73
|
+
return kind, item
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compact_review_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
locators = [
|
|
79
|
+
evidence.get("locator")
|
|
80
|
+
for evidence in item.get("evidence", [])
|
|
81
|
+
if evidence.get("locator")
|
|
82
|
+
]
|
|
83
|
+
compact = {
|
|
84
|
+
"reason": item.get("reason"),
|
|
85
|
+
"locator": locators[0] if locators else None,
|
|
86
|
+
}
|
|
87
|
+
if item.get("revision_hint"):
|
|
88
|
+
compact["revision_hint"] = item["revision_hint"]
|
|
89
|
+
return compact
|