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.
Files changed (39) hide show
  1. ghost_reader/.release-version +1 -0
  2. ghost_reader/__init__.py +3 -0
  3. ghost_reader/agent_loader.py +64 -0
  4. ghost_reader/cli.py +1124 -0
  5. ghost_reader/constants.py +75 -0
  6. ghost_reader/defaults/__init__.py +1 -0
  7. ghost_reader/defaults/personas/__init__.py +1 -0
  8. ghost_reader/defaults/personas/dex.yaml +30 -0
  9. ghost_reader/defaults/personas/elena.yaml +30 -0
  10. ghost_reader/defaults/personas/mara.yaml +30 -0
  11. ghost_reader/defaults/personas/pip.yaml +30 -0
  12. ghost_reader/defaults/personas/rook.yaml +30 -0
  13. ghost_reader/defaults/templates/__init__.py +1 -0
  14. ghost_reader/defaults/templates/blog-review.html +384 -0
  15. ghost_reader/defaults/templates/report.html +1293 -0
  16. ghost_reader/dialogue.py +283 -0
  17. ghost_reader/errors.py +2 -0
  18. ghost_reader/feedback_store.py +56 -0
  19. ghost_reader/io.py +59 -0
  20. ghost_reader/models.py +227 -0
  21. ghost_reader/paths.py +68 -0
  22. ghost_reader/project.py +277 -0
  23. ghost_reader/release.py +56 -0
  24. ghost_reader/report.py +264 -0
  25. ghost_reader/reviews.py +89 -0
  26. ghost_reader/revision.py +165 -0
  27. ghost_reader/round.py +155 -0
  28. ghost_reader/server.py +281 -0
  29. ghost_reader/sync.py +112 -0
  30. ghost_reader/telemetry.py +111 -0
  31. ghost_reader/time.py +20 -0
  32. ghost_reader/validators.py +66 -0
  33. ghost_reader/verify.py +255 -0
  34. ghost_reader-0.0.1.dist-info/METADATA +221 -0
  35. ghost_reader-0.0.1.dist-info/RECORD +39 -0
  36. ghost_reader-0.0.1.dist-info/WHEEL +5 -0
  37. ghost_reader-0.0.1.dist-info/entry_points.txt +2 -0
  38. ghost_reader-0.0.1.dist-info/licenses/LICENSE +21 -0
  39. ghost_reader-0.0.1.dist-info/top_level.txt +1 -0
@@ -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
@@ -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
@@ -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