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/revision.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ghost_reader.constants import SCHEMA_VERSIONS
|
|
8
|
+
from ghost_reader.errors import GhostReaderError
|
|
9
|
+
from ghost_reader.io import write_yaml
|
|
10
|
+
from ghost_reader.paths import artifact_paths
|
|
11
|
+
from ghost_reader.project import load_manifest
|
|
12
|
+
from ghost_reader.reviews import feedback_summary, find_review_item, load_reviews
|
|
13
|
+
from ghost_reader.telemetry import append_event
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def generate_revision_prompt(
|
|
17
|
+
project_root: Path,
|
|
18
|
+
home: Path,
|
|
19
|
+
session_id: str,
|
|
20
|
+
goal: str | None = None,
|
|
21
|
+
preserve: list[str] | None = None,
|
|
22
|
+
) -> dict[str, str]:
|
|
23
|
+
manifest = load_manifest(project_root, session_id)
|
|
24
|
+
reviews = {
|
|
25
|
+
review["persona_id"]: review
|
|
26
|
+
for review in load_reviews(project_root, home, session_id)
|
|
27
|
+
}
|
|
28
|
+
feedback = feedback_summary(project_root, session_id)
|
|
29
|
+
if not feedback["feedback_found"]:
|
|
30
|
+
raise GhostReaderError(
|
|
31
|
+
"Feedback artifact not found. Run `ghost-reader feedback add` before generating a revision prompt."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
selected_reviews: dict[str, dict[str, list[str]]] = defaultdict(
|
|
35
|
+
lambda: {"included_strengths": [], "included_concerns": []}
|
|
36
|
+
)
|
|
37
|
+
selected_details = []
|
|
38
|
+
change_hints = []
|
|
39
|
+
for selected in feedback["selected_items"]:
|
|
40
|
+
persona_id, item_id = selected["target_id"].split(":", 1)
|
|
41
|
+
review = reviews.get(persona_id)
|
|
42
|
+
if not review:
|
|
43
|
+
raise GhostReaderError(
|
|
44
|
+
f"Selected review `{selected['target_id']}` has no stored review artifact."
|
|
45
|
+
)
|
|
46
|
+
found = find_review_item(review, item_id)
|
|
47
|
+
if not found:
|
|
48
|
+
raise GhostReaderError(
|
|
49
|
+
f"Selected review item `{selected['target_id']}` was not found."
|
|
50
|
+
)
|
|
51
|
+
kind, item = found
|
|
52
|
+
bucket = "included_strengths" if kind == "strengths" else "included_concerns"
|
|
53
|
+
selected_reviews[persona_id][bucket].append(item_id)
|
|
54
|
+
selected_details.append(
|
|
55
|
+
{
|
|
56
|
+
"target_id": selected["target_id"],
|
|
57
|
+
"kind": kind[:-1],
|
|
58
|
+
"reason": item.get("reason"),
|
|
59
|
+
"locators": [
|
|
60
|
+
evidence.get("locator")
|
|
61
|
+
for evidence in item.get("evidence", [])
|
|
62
|
+
if evidence.get("locator")
|
|
63
|
+
],
|
|
64
|
+
"reader_effect": item.get("reader_effect"),
|
|
65
|
+
"revision_hint": item.get("revision_hint"),
|
|
66
|
+
"user_note": feedback["user_notes"].get(selected["target_id"], ""),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
if item.get("revision_hint"):
|
|
70
|
+
change_hints.append(item["revision_hint"])
|
|
71
|
+
|
|
72
|
+
preserve_items = preserve or []
|
|
73
|
+
prompt_goal = (
|
|
74
|
+
goal
|
|
75
|
+
or f"Revise {manifest.get('story_unit') or session_id} using selected Ghost Reader feedback."
|
|
76
|
+
)
|
|
77
|
+
current_round = manifest.get("current_round", 1)
|
|
78
|
+
revision_prompt = {
|
|
79
|
+
"schema_version": SCHEMA_VERSIONS["revision_prompt"],
|
|
80
|
+
"session_id": session_id,
|
|
81
|
+
"round": current_round,
|
|
82
|
+
"selected_reviews": [
|
|
83
|
+
{"persona_id": persona_id, **values}
|
|
84
|
+
for persona_id, values in sorted(selected_reviews.items())
|
|
85
|
+
],
|
|
86
|
+
"user_notes": [
|
|
87
|
+
{"target_id": target_id, "note": note}
|
|
88
|
+
for target_id, note in feedback["user_notes"].items()
|
|
89
|
+
],
|
|
90
|
+
"revision_brief": {
|
|
91
|
+
"goal": prompt_goal,
|
|
92
|
+
"preserve": preserve_items,
|
|
93
|
+
"change": change_hints,
|
|
94
|
+
},
|
|
95
|
+
"prompt_text": build_revision_prompt_text(
|
|
96
|
+
session_id,
|
|
97
|
+
manifest.get("story_unit"),
|
|
98
|
+
project_root,
|
|
99
|
+
current_round,
|
|
100
|
+
prompt_goal,
|
|
101
|
+
preserve_items,
|
|
102
|
+
selected_details,
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
prompt_dir = artifact_paths(project_root, session_id)["prompts"]
|
|
106
|
+
yaml_path = prompt_dir / "revision-prompt.yaml"
|
|
107
|
+
markdown_path = prompt_dir / "revision-prompt.md"
|
|
108
|
+
write_yaml(yaml_path, revision_prompt)
|
|
109
|
+
markdown_path.write_text(revision_prompt["prompt_text"], encoding="utf-8")
|
|
110
|
+
append_event(
|
|
111
|
+
home,
|
|
112
|
+
project_root,
|
|
113
|
+
"revision_prompt_generated",
|
|
114
|
+
session_id,
|
|
115
|
+
meta={
|
|
116
|
+
"selected_review_item_ids": [
|
|
117
|
+
item["target_id"] for item in feedback["selected_items"]
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
return {"yaml": str(yaml_path), "markdown": str(markdown_path)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_revision_prompt_text(
|
|
125
|
+
session_id: str,
|
|
126
|
+
story_unit: str | None,
|
|
127
|
+
project_root: Path,
|
|
128
|
+
current_round: int,
|
|
129
|
+
goal: str,
|
|
130
|
+
preserve: list[str],
|
|
131
|
+
selected_details: list[dict[str, Any]],
|
|
132
|
+
) -> str:
|
|
133
|
+
lines = [
|
|
134
|
+
"# Ghost Reader Revision Handoff",
|
|
135
|
+
"",
|
|
136
|
+
f"Session: {session_id}",
|
|
137
|
+
f"Round: {current_round}",
|
|
138
|
+
f"Story unit: {story_unit or session_id}",
|
|
139
|
+
f"Story project root: {project_root}",
|
|
140
|
+
f"Session artifacts: .ghostreader/sessions/{session_id}/",
|
|
141
|
+
"",
|
|
142
|
+
f"Goal: {goal}",
|
|
143
|
+
"",
|
|
144
|
+
"Source handling:",
|
|
145
|
+
"- Use source text already visible in the current conversation or available from the story project.",
|
|
146
|
+
"- If only source locators are available, ask for the affected passages before drafting revised prose.",
|
|
147
|
+
"- Do not reconstruct missing prose from review summaries, locators, or memory.",
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
if preserve:
|
|
151
|
+
lines.append("Preserve:")
|
|
152
|
+
lines.extend(f"- {item}" for item in preserve)
|
|
153
|
+
lines.append("")
|
|
154
|
+
lines.append("Selected feedback:")
|
|
155
|
+
for detail in selected_details:
|
|
156
|
+
lines.append(f"- {detail['target_id']} ({detail['kind']}): {detail['reason']}")
|
|
157
|
+
if detail.get("locators"):
|
|
158
|
+
lines.append(f" Source locators: {', '.join(detail['locators'])}")
|
|
159
|
+
if detail.get("reader_effect"):
|
|
160
|
+
lines.append(f" Reader effect: {detail['reader_effect']}")
|
|
161
|
+
if detail.get("revision_hint"):
|
|
162
|
+
lines.append(f" Revision hint: {detail['revision_hint']}")
|
|
163
|
+
if detail.get("user_note"):
|
|
164
|
+
lines.append(f" User note: {detail['user_note']}")
|
|
165
|
+
return "\n".join(lines) + "\n"
|
ghost_reader/round.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ghost_reader.constants import ROUND_DIRECTORIES
|
|
8
|
+
from ghost_reader.errors import GhostReaderError
|
|
9
|
+
from ghost_reader.paths import active_round_dir, round_dir
|
|
10
|
+
from ghost_reader.project import (
|
|
11
|
+
load_manifest,
|
|
12
|
+
load_round_manifest,
|
|
13
|
+
save_manifest,
|
|
14
|
+
save_round_manifest,
|
|
15
|
+
)
|
|
16
|
+
from ghost_reader.telemetry import append_event
|
|
17
|
+
from ghost_reader.time import now_iso
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_round_structure(round_path: Path) -> None:
|
|
21
|
+
for child in ROUND_DIRECTORIES:
|
|
22
|
+
(round_path / child).mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def round_init(
|
|
26
|
+
project_root: Path,
|
|
27
|
+
home: Path,
|
|
28
|
+
session_id: str,
|
|
29
|
+
story_unit: str | None = None,
|
|
30
|
+
source_file: str | None = None,
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
manifest = load_manifest(project_root, session_id)
|
|
33
|
+
current = manifest.get("current_round", 1)
|
|
34
|
+
new_round = current + 1
|
|
35
|
+
rp = round_dir(project_root, session_id, new_round)
|
|
36
|
+
rp.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
ensure_round_structure(rp)
|
|
38
|
+
|
|
39
|
+
if source_file:
|
|
40
|
+
src = Path(source_file)
|
|
41
|
+
if not src.exists():
|
|
42
|
+
raise GhostReaderError(f"Source file not found: {source_file}")
|
|
43
|
+
shutil.copy2(src, rp / "context" / "source-text.md")
|
|
44
|
+
|
|
45
|
+
save_round_manifest(
|
|
46
|
+
project_root,
|
|
47
|
+
session_id,
|
|
48
|
+
new_round,
|
|
49
|
+
{
|
|
50
|
+
"round_id": new_round,
|
|
51
|
+
"schema_version": 1,
|
|
52
|
+
"previous_round": current,
|
|
53
|
+
"status": "reviews_pending",
|
|
54
|
+
"refinement_count": 0,
|
|
55
|
+
"created_at": now_iso(),
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
manifest["current_round"] = new_round
|
|
60
|
+
manifest.setdefault("rounds", []).append(
|
|
61
|
+
{
|
|
62
|
+
"id": new_round,
|
|
63
|
+
"status": "reviews_pending",
|
|
64
|
+
"created_at": now_iso(),
|
|
65
|
+
"refinement_count": 0,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
if story_unit:
|
|
69
|
+
manifest["story_unit"] = story_unit
|
|
70
|
+
save_manifest(project_root, session_id, manifest)
|
|
71
|
+
|
|
72
|
+
append_event(
|
|
73
|
+
home,
|
|
74
|
+
project_root,
|
|
75
|
+
"round_created",
|
|
76
|
+
session_id,
|
|
77
|
+
meta={"round_id": new_round, "previous_round": current},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return {"round_id": new_round, "path": str(rp), "status": "reviews_pending"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def round_status(project_root: Path, session_id: str) -> dict[str, Any]:
|
|
84
|
+
manifest = load_manifest(project_root, session_id)
|
|
85
|
+
rounds_output: list[dict[str, Any]] = []
|
|
86
|
+
for entry in manifest.get("rounds", []):
|
|
87
|
+
rid = entry["id"]
|
|
88
|
+
try:
|
|
89
|
+
rm = load_round_manifest(project_root, session_id, rid)
|
|
90
|
+
except GhostReaderError:
|
|
91
|
+
rm = {}
|
|
92
|
+
rp = round_dir(project_root, session_id, rid)
|
|
93
|
+
reviews_dir = rp / "reviews"
|
|
94
|
+
review_files = (
|
|
95
|
+
list(reviews_dir.glob("*.review.yaml"))
|
|
96
|
+
if reviews_dir.exists()
|
|
97
|
+
else []
|
|
98
|
+
)
|
|
99
|
+
rounds_output.append(
|
|
100
|
+
{
|
|
101
|
+
"id": rid,
|
|
102
|
+
"status": rm.get("status", entry.get("status", "unknown")),
|
|
103
|
+
"refinement_count": rm.get("refinement_count", 0),
|
|
104
|
+
"review_count": len(review_files),
|
|
105
|
+
"created_at": rm.get("created_at", entry.get("created_at", "")),
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
return {
|
|
109
|
+
"session_id": session_id,
|
|
110
|
+
"current_round": manifest.get("current_round", 1),
|
|
111
|
+
"rounds": rounds_output,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def refine_snapshot(
|
|
116
|
+
project_root: Path, home: Path, session_id: str
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
manifest = load_manifest(project_root, session_id)
|
|
119
|
+
current = manifest.get("current_round", 1)
|
|
120
|
+
rm = load_round_manifest(project_root, session_id, current)
|
|
121
|
+
current_refinement = rm.get("refinement_count", 0)
|
|
122
|
+
new_pass = current_refinement + 1
|
|
123
|
+
|
|
124
|
+
active = active_round_dir(project_root, session_id)
|
|
125
|
+
snapshot_dir = active / "refinements" / f"{new_pass:03d}"
|
|
126
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
|
|
128
|
+
reviews_src = active / "reviews"
|
|
129
|
+
if reviews_src.exists() and any(reviews_src.iterdir()):
|
|
130
|
+
shutil.copytree(reviews_src, snapshot_dir / "reviews", dirs_exist_ok=True)
|
|
131
|
+
|
|
132
|
+
feedback_src = active / "feedback" / "feedback.yaml"
|
|
133
|
+
if feedback_src.exists():
|
|
134
|
+
(snapshot_dir / "feedback").mkdir(parents=True, exist_ok=True)
|
|
135
|
+
shutil.copy2(feedback_src, snapshot_dir / "feedback" / "feedback.yaml")
|
|
136
|
+
|
|
137
|
+
rm["refinement_count"] = new_pass
|
|
138
|
+
save_round_manifest(project_root, session_id, current, rm)
|
|
139
|
+
|
|
140
|
+
for entry in manifest.get("rounds", []):
|
|
141
|
+
if entry["id"] == current:
|
|
142
|
+
entry["refinement_count"] = new_pass
|
|
143
|
+
entry["status"] = rm.get("status", entry.get("status", "unknown"))
|
|
144
|
+
break
|
|
145
|
+
save_manifest(project_root, session_id, manifest)
|
|
146
|
+
|
|
147
|
+
append_event(
|
|
148
|
+
home,
|
|
149
|
+
project_root,
|
|
150
|
+
"refinement_snapshot_created",
|
|
151
|
+
session_id,
|
|
152
|
+
meta={"round_id": current, "refinement_pass": new_pass},
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return {"written": True, "path": str(snapshot_dir), "refinement_pass": new_pass}
|
ghost_reader/server.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from ghost_reader.dialogue import dialogue_append, load_all_dialogue
|
|
13
|
+
from ghost_reader.errors import GhostReaderError
|
|
14
|
+
from ghost_reader.feedback_store import record_feedback
|
|
15
|
+
from ghost_reader.paths import artifact_paths
|
|
16
|
+
from ghost_reader.project import load_manifest
|
|
17
|
+
from ghost_reader.report import render_report
|
|
18
|
+
from ghost_reader.reviews import feedback_summary
|
|
19
|
+
from ghost_reader.verify import verify_session
|
|
20
|
+
|
|
21
|
+
# Set by serve_report after successful bind so tests can discover the actual port
|
|
22
|
+
_bound_port: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cors_headers(handler: SimpleHTTPRequestHandler) -> None:
|
|
26
|
+
origin = handler.headers.get("Origin", "")
|
|
27
|
+
if origin:
|
|
28
|
+
try:
|
|
29
|
+
hostname = urlparse(origin).hostname
|
|
30
|
+
if hostname in ("127.0.0.1", "localhost", "::1"):
|
|
31
|
+
handler.send_header("Access-Control-Allow-Origin", origin)
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
35
|
+
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _json_response(handler: SimpleHTTPRequestHandler, data: dict[str, Any], status: int = 200) -> None:
|
|
39
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
40
|
+
handler.send_response(status)
|
|
41
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
42
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
43
|
+
handler.end_headers()
|
|
44
|
+
handler.wfile.write(body)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
MAX_BODY_SIZE = 1_048_576 # 1 MiB
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_body(handler: SimpleHTTPRequestHandler) -> dict[str, Any] | None:
|
|
51
|
+
length = int(handler.headers.get("Content-Length", 0))
|
|
52
|
+
if not length:
|
|
53
|
+
return None
|
|
54
|
+
if length > MAX_BODY_SIZE:
|
|
55
|
+
return None
|
|
56
|
+
return json.loads(handler.rfile.read(length))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _FeedbackHandler(SimpleHTTPRequestHandler):
|
|
60
|
+
project_root: Path
|
|
61
|
+
home: Path
|
|
62
|
+
session_id: str
|
|
63
|
+
report_subpath: str = ""
|
|
64
|
+
|
|
65
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
66
|
+
self.server._last_active = time.time()
|
|
67
|
+
|
|
68
|
+
def end_headers(self) -> None:
|
|
69
|
+
_cors_headers(self)
|
|
70
|
+
super().end_headers()
|
|
71
|
+
|
|
72
|
+
def do_OPTIONS(self) -> None:
|
|
73
|
+
self.send_response(200)
|
|
74
|
+
_cors_headers(self)
|
|
75
|
+
self.end_headers()
|
|
76
|
+
|
|
77
|
+
def do_POST(self) -> None:
|
|
78
|
+
parsed = urlparse(self.path)
|
|
79
|
+
if parsed.path == "/api/feedback":
|
|
80
|
+
self._handle_feedback()
|
|
81
|
+
elif parsed.path == "/api/dialogue":
|
|
82
|
+
self._handle_dialogue()
|
|
83
|
+
else:
|
|
84
|
+
self.send_error(404)
|
|
85
|
+
|
|
86
|
+
def do_GET(self) -> None:
|
|
87
|
+
parsed = urlparse(self.path)
|
|
88
|
+
if parsed.path == "/api/state":
|
|
89
|
+
self._handle_state()
|
|
90
|
+
elif parsed.path in ("/", "") and self.report_subpath:
|
|
91
|
+
self.send_response(302)
|
|
92
|
+
self.send_header("Location", f"/{self.report_subpath}")
|
|
93
|
+
self.end_headers()
|
|
94
|
+
else:
|
|
95
|
+
super().do_GET()
|
|
96
|
+
|
|
97
|
+
def _handle_feedback(self) -> None:
|
|
98
|
+
try:
|
|
99
|
+
body = _read_body(self)
|
|
100
|
+
if not body:
|
|
101
|
+
return _json_response(self, {"error": "empty body"}, 400)
|
|
102
|
+
feedback_path = record_feedback(
|
|
103
|
+
self.project_root,
|
|
104
|
+
self.home,
|
|
105
|
+
self.session_id,
|
|
106
|
+
body,
|
|
107
|
+
surface="html",
|
|
108
|
+
)
|
|
109
|
+
_json_response(self, {"ok": True, "path": str(feedback_path)})
|
|
110
|
+
except GhostReaderError as exc:
|
|
111
|
+
_json_response(self, {"error": str(exc)}, 400)
|
|
112
|
+
except Exception:
|
|
113
|
+
_json_response(self, {"error": "internal server error"}, 500)
|
|
114
|
+
|
|
115
|
+
def _handle_dialogue(self) -> None:
|
|
116
|
+
try:
|
|
117
|
+
body = _read_body(self)
|
|
118
|
+
if not body:
|
|
119
|
+
return _json_response(self, {"error": "empty body"}, 400)
|
|
120
|
+
persona_id = body.get("persona_id")
|
|
121
|
+
text = body.get("text")
|
|
122
|
+
if not persona_id or not text:
|
|
123
|
+
return _json_response(self, {"error": "persona_id and text required"}, 400)
|
|
124
|
+
result = dialogue_append(
|
|
125
|
+
self.project_root,
|
|
126
|
+
self.home,
|
|
127
|
+
self.session_id,
|
|
128
|
+
persona_id,
|
|
129
|
+
body.get("item_id", ""),
|
|
130
|
+
text,
|
|
131
|
+
thread_id=body.get("thread_id"),
|
|
132
|
+
round_id=body.get("round_id"),
|
|
133
|
+
item_uid=body.get("item_uid"),
|
|
134
|
+
)
|
|
135
|
+
_json_response(self, {"ok": True, "thread_id": result["thread_id"], "turn_index": result["turn_index"]})
|
|
136
|
+
except GhostReaderError as exc:
|
|
137
|
+
_json_response(self, {"error": str(exc)}, 400)
|
|
138
|
+
except Exception:
|
|
139
|
+
_json_response(self, {"error": "internal server error"}, 500)
|
|
140
|
+
|
|
141
|
+
def _handle_state(self) -> None:
|
|
142
|
+
try:
|
|
143
|
+
manifest = load_manifest(self.project_root, self.session_id)
|
|
144
|
+
try:
|
|
145
|
+
verification = verify_session(self.project_root, self.home, self.session_id)
|
|
146
|
+
except Exception:
|
|
147
|
+
verification = {"status": "incomplete", "ok": False}
|
|
148
|
+
feedback = feedback_summary(self.project_root, self.session_id)
|
|
149
|
+
dialogue = load_all_dialogue(
|
|
150
|
+
self.project_root, self.session_id, manifest.get("personas", [])
|
|
151
|
+
)
|
|
152
|
+
_json_response(self, {
|
|
153
|
+
"session_id": self.session_id,
|
|
154
|
+
"current_round": manifest.get("current_round", 1),
|
|
155
|
+
"verification_status": verification.get("status"),
|
|
156
|
+
"feedback_items": feedback.get("selected_items", []),
|
|
157
|
+
"dialogue_threads": _serialize_threads(dialogue),
|
|
158
|
+
"rounds": manifest.get("rounds", []),
|
|
159
|
+
"story_unit": manifest.get("story_unit"),
|
|
160
|
+
"personas": manifest.get("personas", []),
|
|
161
|
+
})
|
|
162
|
+
except GhostReaderError as exc:
|
|
163
|
+
_json_response(self, {"error": str(exc)}, 400)
|
|
164
|
+
except Exception:
|
|
165
|
+
_json_response(self, {"error": "internal server error"}, 500)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _serialize_threads(dialogue: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
|
169
|
+
result: dict[str, list[dict[str, Any]]] = {}
|
|
170
|
+
for persona_id, turns in dialogue.items():
|
|
171
|
+
serialized = []
|
|
172
|
+
for turn in turns:
|
|
173
|
+
serialized.append({
|
|
174
|
+
"turn_index": turn.get("turn_index"),
|
|
175
|
+
"timestamp": turn.get("timestamp"),
|
|
176
|
+
"user": turn.get("user", ""),
|
|
177
|
+
"persona": turn.get("persona", ""),
|
|
178
|
+
"thread_id": turn.get("thread_id", ""),
|
|
179
|
+
})
|
|
180
|
+
if serialized:
|
|
181
|
+
result[persona_id] = serialized
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _build_handler_class(project_root: Path, home: Path, session_id: str, report_subpath: str = "") -> type[_FeedbackHandler]:
|
|
186
|
+
class _BoundHandler(_FeedbackHandler):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
_BoundHandler.project_root = project_root
|
|
190
|
+
_BoundHandler.home = home
|
|
191
|
+
_BoundHandler.session_id = session_id
|
|
192
|
+
_BoundHandler.directory = str(project_root)
|
|
193
|
+
_BoundHandler.report_subpath = report_subpath
|
|
194
|
+
return _BoundHandler
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def serve_report(
|
|
198
|
+
project_root: Path,
|
|
199
|
+
home: Path,
|
|
200
|
+
session_id: str,
|
|
201
|
+
port: int = 8765,
|
|
202
|
+
timeout: int = 0,
|
|
203
|
+
render: bool | None = None,
|
|
204
|
+
command_prefix: str = "ghost-reader",
|
|
205
|
+
template_name: str = "report",
|
|
206
|
+
pipe_wfd: int | None = None,
|
|
207
|
+
) -> str:
|
|
208
|
+
payload_path = artifact_paths(project_root, session_id)["report"] / "payload.json"
|
|
209
|
+
should_render = render if render is not None else not payload_path.exists()
|
|
210
|
+
if should_render:
|
|
211
|
+
print("Auto-rendering report...")
|
|
212
|
+
render_report(project_root, home, session_id, command_prefix, template_name)
|
|
213
|
+
|
|
214
|
+
handler_class = _build_handler_class(project_root, home, session_id)
|
|
215
|
+
global _bound_port
|
|
216
|
+
_bound_port = None # Reset so each serve_report call gets a fresh port
|
|
217
|
+
actual_port = port
|
|
218
|
+
server = None
|
|
219
|
+
for attempt in range(port, port + 100):
|
|
220
|
+
try:
|
|
221
|
+
server = HTTPServer(("127.0.0.1", attempt), handler_class)
|
|
222
|
+
actual_port = server.server_address[1]
|
|
223
|
+
_bound_port = actual_port
|
|
224
|
+
break
|
|
225
|
+
except OSError:
|
|
226
|
+
continue
|
|
227
|
+
if server is None:
|
|
228
|
+
raise RuntimeError(f"Could not bind to any port starting from {port}")
|
|
229
|
+
|
|
230
|
+
url = f"http://localhost:{actual_port}"
|
|
231
|
+
manifest = load_manifest(project_root, session_id)
|
|
232
|
+
current_round = manifest.get("current_round", 1)
|
|
233
|
+
report_rel = f".ghostreader/sessions/{session_id}/round-{current_round}/report/index.html"
|
|
234
|
+
handler_class.report_subpath = report_rel
|
|
235
|
+
print(f"Ghost Reader serving at {url}/{report_rel}")
|
|
236
|
+
|
|
237
|
+
# Communicate port to parent AFTER all fallible setup completes
|
|
238
|
+
# but BEFORE entering the blocking serve loop. If load_manifest or
|
|
239
|
+
# any other setup step fails, the child's except block writes
|
|
240
|
+
# "error\n" to the pipe and the parent never sees "ok:", avoiding
|
|
241
|
+
# a silent crash where the parent thinks the server is running.
|
|
242
|
+
if pipe_wfd is not None:
|
|
243
|
+
os.write(pipe_wfd, f"ok:{actual_port}\n".encode())
|
|
244
|
+
|
|
245
|
+
if timeout > 0:
|
|
246
|
+
_run_with_timeout(server, url, timeout, session_id)
|
|
247
|
+
else:
|
|
248
|
+
_run_forever(server)
|
|
249
|
+
return url
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _run_forever(server: HTTPServer) -> None:
|
|
253
|
+
try:
|
|
254
|
+
server.serve_forever()
|
|
255
|
+
except KeyboardInterrupt:
|
|
256
|
+
pass
|
|
257
|
+
finally:
|
|
258
|
+
server.server_close()
|
|
259
|
+
print("Server stopped")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _run_with_timeout(
|
|
263
|
+
server: HTTPServer, url: str, timeout: int, session_id: str
|
|
264
|
+
) -> None:
|
|
265
|
+
server._last_active = time.time()
|
|
266
|
+
server.timeout = 1.0
|
|
267
|
+
try:
|
|
268
|
+
while True:
|
|
269
|
+
server.handle_request()
|
|
270
|
+
if time.time() - server._last_active > timeout:
|
|
271
|
+
print(
|
|
272
|
+
f"\nGhost Reader server idle for {timeout}s — shutting down.\n"
|
|
273
|
+
f"Tip: use `ghost-reader serve --session {session_id} --detach` "
|
|
274
|
+
f"for review sessions where you need time to deliberate.\n",
|
|
275
|
+
file=sys.stderr,
|
|
276
|
+
)
|
|
277
|
+
break
|
|
278
|
+
except KeyboardInterrupt:
|
|
279
|
+
pass
|
|
280
|
+
finally:
|
|
281
|
+
server.server_close()
|
|
282
|
+
print("Server stopped")
|