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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ghost_reader.agent_loader import discover_agent_personas
|
|
4
|
+
|
|
5
|
+
DEFAULT_PERSONAS = ["mara", "dex", "pip"]
|
|
6
|
+
LEGACY_AVAILABLE_PERSONAS = ["mara", "dex", "pip", "elena", "rook"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _agent_home() -> Path | None:
|
|
10
|
+
for candidate in (Path(__file__).resolve().parents[2], Path.cwd()):
|
|
11
|
+
if (candidate / "agents").exists():
|
|
12
|
+
return candidate
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _available_personas() -> list[str]:
|
|
17
|
+
home = _agent_home()
|
|
18
|
+
if home is None:
|
|
19
|
+
return LEGACY_AVAILABLE_PERSONAS
|
|
20
|
+
discovered = discover_agent_personas(home)
|
|
21
|
+
if not discovered:
|
|
22
|
+
return LEGACY_AVAILABLE_PERSONAS
|
|
23
|
+
legacy = [persona for persona in LEGACY_AVAILABLE_PERSONAS if persona in discovered]
|
|
24
|
+
extras = sorted(persona for persona in discovered if persona not in legacy)
|
|
25
|
+
return legacy + extras
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
AVAILABLE_PERSONAS = _available_personas()
|
|
29
|
+
|
|
30
|
+
SCHEMA_VERSIONS = {
|
|
31
|
+
"persona": 1,
|
|
32
|
+
"review": 2,
|
|
33
|
+
"revision_prompt": 2,
|
|
34
|
+
"feedback": 2,
|
|
35
|
+
"session_manifest": 1,
|
|
36
|
+
"dialogue_index": 1,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
TEMPLATE_VERSION = "2"
|
|
40
|
+
|
|
41
|
+
TELEMETRY_EVENTS = {
|
|
42
|
+
"session_created",
|
|
43
|
+
"context_note_written",
|
|
44
|
+
"review_completed",
|
|
45
|
+
"html_rendered",
|
|
46
|
+
"feedback_recorded",
|
|
47
|
+
"revision_prompt_generated",
|
|
48
|
+
"sync_attempted",
|
|
49
|
+
"dialogue_turn_created",
|
|
50
|
+
"review_item_questioned",
|
|
51
|
+
"persona_compared",
|
|
52
|
+
"dialogue_included_in_revision_prompt",
|
|
53
|
+
"round_created",
|
|
54
|
+
"refinement_snapshot_created",
|
|
55
|
+
"error_occurred",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
CONTENT_SENSITIVE_TELEMETRY_KEYS = {
|
|
59
|
+
"raw_story_text",
|
|
60
|
+
"story_text",
|
|
61
|
+
"full_review_text",
|
|
62
|
+
"private_note",
|
|
63
|
+
"private_notes",
|
|
64
|
+
"inferred_stance",
|
|
65
|
+
"stance",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
ROUND_DIRECTORIES = [
|
|
69
|
+
"context",
|
|
70
|
+
"reviews",
|
|
71
|
+
"feedback",
|
|
72
|
+
"prompts",
|
|
73
|
+
"report",
|
|
74
|
+
"refinements",
|
|
75
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged Ghost Reader defaults."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged default personas."""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: dex
|
|
3
|
+
name: Dex
|
|
4
|
+
reader_type: "Grimdark skeptic and trope-fatigued reader"
|
|
5
|
+
profile:
|
|
6
|
+
summary: "Reads for consequence, tonal honesty, and earned brutality."
|
|
7
|
+
favorite_genres:
|
|
8
|
+
- "grimdark fantasy"
|
|
9
|
+
- "dark fantasy"
|
|
10
|
+
- "antihero stories"
|
|
11
|
+
preferences:
|
|
12
|
+
seeks:
|
|
13
|
+
- "choices with irreversible cost"
|
|
14
|
+
- "characters who are shaped by pressure"
|
|
15
|
+
- "worlds that resist easy moral cleanup"
|
|
16
|
+
dislikes:
|
|
17
|
+
- "edginess without consequence"
|
|
18
|
+
- "villains who posture more than act"
|
|
19
|
+
- "soft resets after supposedly brutal events"
|
|
20
|
+
review_focus:
|
|
21
|
+
primary:
|
|
22
|
+
- "consequence"
|
|
23
|
+
- "tone credibility"
|
|
24
|
+
- "trope freshness"
|
|
25
|
+
secondary:
|
|
26
|
+
- "character pressure"
|
|
27
|
+
- "scene stakes"
|
|
28
|
+
feedback_style:
|
|
29
|
+
voice: "skeptical, blunt, allergic to empty darkness"
|
|
30
|
+
default_question: "What did this scene make impossible to ignore?"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: elena
|
|
3
|
+
name: Elena
|
|
4
|
+
reader_type: "Literary-fiction-leaning, prose-sensitive reader"
|
|
5
|
+
profile:
|
|
6
|
+
summary: "Reads for emotional precision, prose control, subtext, and resonance."
|
|
7
|
+
favorite_genres:
|
|
8
|
+
- "literary fiction"
|
|
9
|
+
- "character studies"
|
|
10
|
+
- "quiet speculative fiction"
|
|
11
|
+
preferences:
|
|
12
|
+
seeks:
|
|
13
|
+
- "specific sensory and emotional texture"
|
|
14
|
+
- "subtext that changes the scene"
|
|
15
|
+
- "prose rhythm that fits the moment"
|
|
16
|
+
dislikes:
|
|
17
|
+
- "generic interiority"
|
|
18
|
+
- "theme stated before it is dramatized"
|
|
19
|
+
- "overwritten sentences that blur action"
|
|
20
|
+
review_focus:
|
|
21
|
+
primary:
|
|
22
|
+
- "prose precision"
|
|
23
|
+
- "subtext"
|
|
24
|
+
- "emotional specificity"
|
|
25
|
+
secondary:
|
|
26
|
+
- "image patterning"
|
|
27
|
+
- "character interiority"
|
|
28
|
+
feedback_style:
|
|
29
|
+
voice: "attentive, prose-sensitive, exacting"
|
|
30
|
+
default_question: "What is the scene feeling beneath what it says?"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: mara
|
|
3
|
+
name: Mara
|
|
4
|
+
reader_type: "LitRPG progression reader"
|
|
5
|
+
profile:
|
|
6
|
+
summary: "Reads for earned progression, clear mechanics, and strategic choices."
|
|
7
|
+
favorite_genres:
|
|
8
|
+
- "LitRPG"
|
|
9
|
+
- "progression fantasy"
|
|
10
|
+
- "dungeon crawl"
|
|
11
|
+
preferences:
|
|
12
|
+
seeks:
|
|
13
|
+
- "visible character progression"
|
|
14
|
+
- "mechanics that affect choices"
|
|
15
|
+
- "combat with tactical consequences"
|
|
16
|
+
dislikes:
|
|
17
|
+
- "stat mentions without stakes"
|
|
18
|
+
- "dungeon rooms that feel interchangeable"
|
|
19
|
+
- "power gains that feel unearned"
|
|
20
|
+
review_focus:
|
|
21
|
+
primary:
|
|
22
|
+
- "progression payoff"
|
|
23
|
+
- "system clarity"
|
|
24
|
+
- "choice consequence"
|
|
25
|
+
secondary:
|
|
26
|
+
- "combat readability"
|
|
27
|
+
- "pacing momentum"
|
|
28
|
+
feedback_style:
|
|
29
|
+
voice: "direct, genre-literate, specific"
|
|
30
|
+
default_question: "What changed because of this scene?"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: pip
|
|
3
|
+
name: Pip
|
|
4
|
+
reader_type: "Casual voracious reader with high throughput and short attention"
|
|
5
|
+
profile:
|
|
6
|
+
summary: "Reads quickly for momentum, clarity, hooks, and emotional payoff."
|
|
7
|
+
favorite_genres:
|
|
8
|
+
- "serial fiction"
|
|
9
|
+
- "fanfiction"
|
|
10
|
+
- "commercial fantasy"
|
|
11
|
+
preferences:
|
|
12
|
+
seeks:
|
|
13
|
+
- "fast orientation"
|
|
14
|
+
- "clear emotional stakes"
|
|
15
|
+
- "chapters that make the next click obvious"
|
|
16
|
+
dislikes:
|
|
17
|
+
- "slow openings without a hook"
|
|
18
|
+
- "unclear scene goals"
|
|
19
|
+
- "dense explanation before emotional investment"
|
|
20
|
+
review_focus:
|
|
21
|
+
primary:
|
|
22
|
+
- "hook strength"
|
|
23
|
+
- "pacing momentum"
|
|
24
|
+
- "clarity"
|
|
25
|
+
secondary:
|
|
26
|
+
- "emotional readability"
|
|
27
|
+
- "chapter ending pull"
|
|
28
|
+
feedback_style:
|
|
29
|
+
voice: "plainspoken, impatient with friction, concrete"
|
|
30
|
+
default_question: "Where did I want to keep reading, and where did I drift?"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
schema_version: 1
|
|
2
|
+
id: rook
|
|
3
|
+
name: Rook
|
|
4
|
+
reader_type: "Action-first, visual, dialogue-skimming reader"
|
|
5
|
+
profile:
|
|
6
|
+
summary: "Reads for spatial clarity, kinetic scenes, sharp dialogue, and visible stakes."
|
|
7
|
+
favorite_genres:
|
|
8
|
+
- "action fantasy"
|
|
9
|
+
- "thriller"
|
|
10
|
+
- "cinematic adventure"
|
|
11
|
+
preferences:
|
|
12
|
+
seeks:
|
|
13
|
+
- "clear physical blocking"
|
|
14
|
+
- "decisions under immediate pressure"
|
|
15
|
+
- "dialogue that changes the tactical situation"
|
|
16
|
+
dislikes:
|
|
17
|
+
- "muddy action geography"
|
|
18
|
+
- "long exchanges without changed stakes"
|
|
19
|
+
- "description that pauses impact"
|
|
20
|
+
review_focus:
|
|
21
|
+
primary:
|
|
22
|
+
- "action readability"
|
|
23
|
+
- "visual clarity"
|
|
24
|
+
- "dialogue utility"
|
|
25
|
+
secondary:
|
|
26
|
+
- "scene momentum"
|
|
27
|
+
- "stakes visibility"
|
|
28
|
+
feedback_style:
|
|
29
|
+
voice: "visual, action-oriented, practical"
|
|
30
|
+
default_question: "Could I see what changed from beat to beat?"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Packaged default templates."""
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" type="image/png">
|
|
7
|
+
<title>Ghost Reader — Beta Review</title>
|
|
8
|
+
<style>
|
|
9
|
+
/* ═══════════════════════════════════════════════
|
|
10
|
+
Design tokens — warm editorial manuscript review
|
|
11
|
+
═══════════════════════════════════════════════ */
|
|
12
|
+
:root {
|
|
13
|
+
color-scheme: light;
|
|
14
|
+
--bg: #FAF9F5;
|
|
15
|
+
--panel: #FFFFFF;
|
|
16
|
+
--ink: #141413;
|
|
17
|
+
--text-body: #3D3D3A;
|
|
18
|
+
--muted: #87867F;
|
|
19
|
+
--line: #D1CFC5;
|
|
20
|
+
--line-light: #F0EEE6;
|
|
21
|
+
--accent: #D97757;
|
|
22
|
+
--strength: #788C5D;
|
|
23
|
+
--strength-soft: rgba(120, 140, 93, 0.14);
|
|
24
|
+
--concern: #B04A3F;
|
|
25
|
+
--concern-soft: rgba(176, 74, 63, 0.10);
|
|
26
|
+
--serif: ui-serif, Georgia, "Times New Roman", Times, serif;
|
|
27
|
+
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
28
|
+
--mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
|
29
|
+
--radius-row: 8px;
|
|
30
|
+
--radius-panel: 12px;
|
|
31
|
+
--radius-pill: 999px;
|
|
32
|
+
--border: 1.5px solid var(--line);
|
|
33
|
+
--border-light: 1px solid var(--line-light);
|
|
34
|
+
--t-fast: 0.12s ease;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ═══════════════════════════════════════════════
|
|
38
|
+
Page shell — warm editorial
|
|
39
|
+
═══════════════════════════════════════════════ */
|
|
40
|
+
* { box-sizing: border-box; }
|
|
41
|
+
body {
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 56px 24px 96px;
|
|
44
|
+
max-width: 680px;
|
|
45
|
+
margin-left: auto;
|
|
46
|
+
margin-right: auto;
|
|
47
|
+
background: var(--bg);
|
|
48
|
+
color: var(--text-body);
|
|
49
|
+
font-family: var(--serif);
|
|
50
|
+
font-size: 16px;
|
|
51
|
+
line-height: 1.7;
|
|
52
|
+
-webkit-font-smoothing: antialiased;
|
|
53
|
+
text-rendering: optimizeLegibility;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ═══════════════════════════════════════════════
|
|
57
|
+
Header
|
|
58
|
+
═══════════════════════════════════════════════ */
|
|
59
|
+
header {
|
|
60
|
+
border-bottom: var(--border);
|
|
61
|
+
padding-bottom: var(--sp-3, 24px);
|
|
62
|
+
margin-bottom: var(--sp-6, 40px);
|
|
63
|
+
}
|
|
64
|
+
.eyebrow {
|
|
65
|
+
font-family: var(--mono);
|
|
66
|
+
font-size: 11px; text-transform: uppercase;
|
|
67
|
+
letter-spacing: 0.06em; color: var(--muted);
|
|
68
|
+
}
|
|
69
|
+
h1 {
|
|
70
|
+
margin: 8px 0 0;
|
|
71
|
+
font-family: var(--serif); font-size: 28px;
|
|
72
|
+
font-weight: 500; line-height: 1.2; letter-spacing: -0.01em;
|
|
73
|
+
color: var(--ink);
|
|
74
|
+
}
|
|
75
|
+
.story-unit {
|
|
76
|
+
font-family: var(--sans); font-size: 15px;
|
|
77
|
+
color: var(--muted); margin-top: 4px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ═══════════════════════════════════════════════
|
|
81
|
+
Persona review sections
|
|
82
|
+
═══════════════════════════════════════════════ */
|
|
83
|
+
.persona-review { margin-bottom: 48px; }
|
|
84
|
+
.persona-header { margin-bottom: 20px; }
|
|
85
|
+
.persona-name {
|
|
86
|
+
font-family: var(--mono); font-size: 11px;
|
|
87
|
+
text-transform: uppercase; letter-spacing: .08em;
|
|
88
|
+
color: var(--accent); font-weight: 600; margin-bottom: 4px;
|
|
89
|
+
}
|
|
90
|
+
.persona-title {
|
|
91
|
+
font-family: var(--serif); font-size: 22px;
|
|
92
|
+
font-weight: 500; margin: 0; color: var(--ink);
|
|
93
|
+
}
|
|
94
|
+
.persona-scores {
|
|
95
|
+
display: flex; gap: 16px; flex-wrap: wrap; margin-top: 8px;
|
|
96
|
+
font-family: var(--mono); font-size: 12px;
|
|
97
|
+
}
|
|
98
|
+
.score-item { color: var(--muted); }
|
|
99
|
+
.score-item b { color: var(--text-body); font-weight: 600; }
|
|
100
|
+
.persona-section { margin-top: 24px; }
|
|
101
|
+
.section-label {
|
|
102
|
+
font-family: var(--mono); font-size: 11px;
|
|
103
|
+
text-transform: uppercase; letter-spacing: .06em;
|
|
104
|
+
border-bottom: var(--border); padding-bottom: 6px; margin-bottom: 12px;
|
|
105
|
+
}
|
|
106
|
+
.section-label.strengths { color: var(--strength); border-color: var(--strength); }
|
|
107
|
+
.section-label.concerns { color: var(--concern); border-color: var(--concern); }
|
|
108
|
+
.review-item {
|
|
109
|
+
margin-bottom: 16px; padding-left: 16px;
|
|
110
|
+
border-left: 3px solid var(--line);
|
|
111
|
+
}
|
|
112
|
+
.review-item.strength { border-left-color: var(--strength); }
|
|
113
|
+
.review-item.concern { border-left-color: var(--concern); }
|
|
114
|
+
.item-id {
|
|
115
|
+
font-family: var(--mono); font-size: 11px; color: var(--muted); margin-right: 6px;
|
|
116
|
+
}
|
|
117
|
+
.item-reason { margin: 0; }
|
|
118
|
+
.item-effect {
|
|
119
|
+
font-family: var(--sans); font-size: 14px;
|
|
120
|
+
color: var(--muted); font-style: italic; margin: 4px 0 0;
|
|
121
|
+
}
|
|
122
|
+
.item-hint {
|
|
123
|
+
font-family: var(--sans); font-size: 14px;
|
|
124
|
+
color: var(--accent); margin: 4px 0 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ═══════════════════════════════════════════════
|
|
128
|
+
Story text with inline annotations
|
|
129
|
+
═══════════════════════════════════════════════ */
|
|
130
|
+
.source-section { margin-top: 48px; padding-top: 32px; border-top: var(--border); }
|
|
131
|
+
.source-title {
|
|
132
|
+
font-family: var(--mono); font-size: 11px;
|
|
133
|
+
text-transform: uppercase; letter-spacing: .06em;
|
|
134
|
+
color: var(--muted); margin-bottom: 20px;
|
|
135
|
+
}
|
|
136
|
+
.paragraph { margin-bottom: 24px; }
|
|
137
|
+
.paragraph-num {
|
|
138
|
+
font-family: var(--mono); font-size: 11px;
|
|
139
|
+
color: var(--muted); margin-bottom: 4px;
|
|
140
|
+
}
|
|
141
|
+
.paragraph-text { margin: 0; }
|
|
142
|
+
.paragraph-annotations {
|
|
143
|
+
display: grid; gap: 8px; margin-top: 8px;
|
|
144
|
+
}
|
|
145
|
+
.annotation {
|
|
146
|
+
display: block;
|
|
147
|
+
font-family: var(--sans); font-size: 12px;
|
|
148
|
+
padding: 8px 10px; border-radius: var(--radius-row);
|
|
149
|
+
border-left: 3px solid;
|
|
150
|
+
}
|
|
151
|
+
.annotation.strength {
|
|
152
|
+
background: var(--strength-soft); border-color: var(--strength);
|
|
153
|
+
color: var(--strength);
|
|
154
|
+
}
|
|
155
|
+
.annotation.concern {
|
|
156
|
+
background: var(--concern-soft); border-color: var(--concern);
|
|
157
|
+
color: var(--concern);
|
|
158
|
+
}
|
|
159
|
+
.annotation-label {
|
|
160
|
+
display: block;
|
|
161
|
+
font-family: var(--mono); font-size: 11px;
|
|
162
|
+
text-transform: uppercase; margin-bottom: 3px;
|
|
163
|
+
opacity: 0.8;
|
|
164
|
+
}
|
|
165
|
+
.annotation-text { color: var(--text-body); line-height: 1.35; }
|
|
166
|
+
|
|
167
|
+
/* ═══════════════════════════════════════════════
|
|
168
|
+
Revision prompt panel
|
|
169
|
+
═══════════════════════════════════════════════ */
|
|
170
|
+
.revision-section {
|
|
171
|
+
margin-top: 48px; padding: 24px;
|
|
172
|
+
background: var(--panel); border: var(--border);
|
|
173
|
+
border-radius: var(--radius-panel);
|
|
174
|
+
}
|
|
175
|
+
.revision-title {
|
|
176
|
+
font-family: var(--mono); font-size: 12px;
|
|
177
|
+
font-weight: 600; color: var(--accent);
|
|
178
|
+
text-transform: uppercase; letter-spacing: .06em;
|
|
179
|
+
margin-bottom: 12px;
|
|
180
|
+
}
|
|
181
|
+
.revision-text {
|
|
182
|
+
font-family: var(--serif); font-size: 15px;
|
|
183
|
+
white-space: pre-wrap; margin: 0; color: var(--text-body);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ═══════════════════════════════════════════════
|
|
187
|
+
Footer
|
|
188
|
+
═══════════════════════════════════════════════ */
|
|
189
|
+
footer {
|
|
190
|
+
margin-top: 64px; padding-top: 20px;
|
|
191
|
+
border-top: 1px solid var(--line);
|
|
192
|
+
font-family: var(--mono); font-size: 12px; color: var(--muted);
|
|
193
|
+
text-align: center;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ═══════════════════════════════════════════════
|
|
197
|
+
Reduced motion
|
|
198
|
+
═══════════════════════════════════════════════ */
|
|
199
|
+
@media (prefers-reduced-motion: reduce) {
|
|
200
|
+
*, *::before, *::after {
|
|
201
|
+
animation-duration: 0.01ms !important;
|
|
202
|
+
animation-iteration-count: 1 !important;
|
|
203
|
+
transition-duration: 0.01ms !important;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<header>
|
|
210
|
+
<div class="eyebrow">Beta Reader Review</div>
|
|
211
|
+
<h1 id="story-title">Ghost Reader Report</h1>
|
|
212
|
+
<div class="story-unit" id="story-unit"></div>
|
|
213
|
+
</header>
|
|
214
|
+
|
|
215
|
+
<main id="reviews"></main>
|
|
216
|
+
|
|
217
|
+
<section class="source-section" id="source-section" style="display:none">
|
|
218
|
+
<div class="source-title">Story Text with Annotations</div>
|
|
219
|
+
<div id="source-paragraphs"></div>
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
<div class="revision-section" id="revision-section" style="display:none">
|
|
223
|
+
<div class="revision-title">Revision Prompt</div>
|
|
224
|
+
<p class="revision-text" id="revision-prompt"></p>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<footer id="report-footer">
|
|
228
|
+
</footer>
|
|
229
|
+
|
|
230
|
+
<script type="application/json" id="payload-data">__GHOST_READER_PAYLOAD__</script>
|
|
231
|
+
<script>
|
|
232
|
+
const payload = JSON.parse(document.getElementById("payload-data").textContent);
|
|
233
|
+
|
|
234
|
+
function esc(s) {
|
|
235
|
+
return String(s)
|
|
236
|
+
.replace(/&/g, "&")
|
|
237
|
+
.replace(/</g, "<")
|
|
238
|
+
.replace(/>/g, ">")
|
|
239
|
+
.replace(/"/g, """);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function render() {
|
|
243
|
+
document.getElementById("story-title").textContent =
|
|
244
|
+
payload.story_unit || "Ghost Reader Report";
|
|
245
|
+
document.getElementById("story-unit").textContent = payload.session_id;
|
|
246
|
+
|
|
247
|
+
const reviewsEl = document.getElementById("reviews");
|
|
248
|
+
reviewsEl.innerHTML = payload.reviews.map((review) => {
|
|
249
|
+
const scores = review.scores || {};
|
|
250
|
+
const scoreItems = Object.entries(scores)
|
|
251
|
+
.filter(([, v]) => v != null)
|
|
252
|
+
.map(([k, v]) => `<span class="score-item"><b>${esc(v)}</b>/10 ${esc(k.replace("_", " "))}</span>`)
|
|
253
|
+
.join("");
|
|
254
|
+
|
|
255
|
+
const strengthsItems = (review.strengths || [])
|
|
256
|
+
.map((item) => `
|
|
257
|
+
<div class="review-item strength">
|
|
258
|
+
<span class="item-id">${esc(item.id)}</span>
|
|
259
|
+
<p class="item-reason">${esc(item.reason)}</p>
|
|
260
|
+
${item.reader_effect ? `<p class="item-effect">Reader effect: ${esc(item.reader_effect)}</p>` : ""}
|
|
261
|
+
</div>
|
|
262
|
+
`).join("");
|
|
263
|
+
|
|
264
|
+
const concernsItems = (review.concerns || [])
|
|
265
|
+
.map((item) => `
|
|
266
|
+
<div class="review-item concern">
|
|
267
|
+
<span class="item-id">${esc(item.id)}</span>
|
|
268
|
+
<p class="item-reason">${esc(item.reason)}</p>
|
|
269
|
+
${item.reader_effect ? `<p class="item-effect">Reader effect: ${esc(item.reader_effect)}</p>` : ""}
|
|
270
|
+
${item.revision_hint ? `<p class="item-hint">Hint: ${esc(item.revision_hint)}</p>` : ""}
|
|
271
|
+
</div>
|
|
272
|
+
`).join("");
|
|
273
|
+
|
|
274
|
+
return `
|
|
275
|
+
<div class="persona-review">
|
|
276
|
+
<div class="persona-header">
|
|
277
|
+
<div class="persona-name">${esc(review.persona_name || review.persona_id)}</div>
|
|
278
|
+
<h2 class="persona-title">${esc(review.overall?.summary || review.persona_id + " review")}</h2>
|
|
279
|
+
${scoreItems ? `<div class="persona-scores">${scoreItems}</div>` : ""}
|
|
280
|
+
</div>
|
|
281
|
+
${strengthsItems ? `
|
|
282
|
+
<div class="persona-section">
|
|
283
|
+
<div class="section-label strengths">What Works</div>
|
|
284
|
+
${strengthsItems}
|
|
285
|
+
</div>
|
|
286
|
+
` : ""}
|
|
287
|
+
${concernsItems ? `
|
|
288
|
+
<div class="persona-section">
|
|
289
|
+
<div class="section-label concerns">Areas to Review</div>
|
|
290
|
+
${concernsItems}
|
|
291
|
+
</div>
|
|
292
|
+
` : ""}
|
|
293
|
+
</div>
|
|
294
|
+
`;
|
|
295
|
+
}).join("");
|
|
296
|
+
|
|
297
|
+
const source = payload.source_text || {};
|
|
298
|
+
if (source.found && source.paragraphs?.length) {
|
|
299
|
+
const annotatedParagraphs = new Map();
|
|
300
|
+
const paragraphIdsFromLocator = (locator) => {
|
|
301
|
+
const ids = new Set();
|
|
302
|
+
const pattern = /paragraphs?\s+(\d+)(?:\s*[-–]\s*(\d+))?/gi;
|
|
303
|
+
for (const match of (locator || "").matchAll(pattern)) {
|
|
304
|
+
const start = Number(match[1]);
|
|
305
|
+
const end = Number(match[2] || match[1]);
|
|
306
|
+
for (let id = start; id <= end; id += 1) ids.add(id);
|
|
307
|
+
}
|
|
308
|
+
return ids;
|
|
309
|
+
};
|
|
310
|
+
const useStructuredLocators = source.paragraphs.some((paragraph) =>
|
|
311
|
+
(paragraph.source_ids || []).length || (paragraph.locators || []).length
|
|
312
|
+
);
|
|
313
|
+
const itemMatchesParagraph = (item, paragraph) => {
|
|
314
|
+
const sourceIds = new Set((item.evidence || []).map((e) => e.source_id).filter(Boolean));
|
|
315
|
+
const paragraphSourceIds = new Set(paragraph.source_ids || []);
|
|
316
|
+
for (const sourceId of sourceIds) {
|
|
317
|
+
if (paragraphSourceIds.has(sourceId)) return true;
|
|
318
|
+
}
|
|
319
|
+
const locators = new Set((item.evidence || []).map((e) => e.locator).filter(Boolean));
|
|
320
|
+
const paragraphLocators = new Set(paragraph.locators || []);
|
|
321
|
+
for (const locator of locators) {
|
|
322
|
+
if (paragraphLocators.has(locator)) return true;
|
|
323
|
+
}
|
|
324
|
+
if (useStructuredLocators) return false;
|
|
325
|
+
for (const locator of locators) {
|
|
326
|
+
if (paragraphIdsFromLocator(locator).has(paragraph.id)) return true;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
};
|
|
330
|
+
for (const review of payload.reviews || []) {
|
|
331
|
+
for (const item of [...(review.strengths || []), ...(review.concerns || [])]) {
|
|
332
|
+
for (const paragraph of source.paragraphs) {
|
|
333
|
+
if (!itemMatchesParagraph(item, paragraph)) continue;
|
|
334
|
+
if (!annotatedParagraphs.has(paragraph.id)) annotatedParagraphs.set(paragraph.id, []);
|
|
335
|
+
annotatedParagraphs.get(paragraph.id).push({
|
|
336
|
+
kind: review.strengths?.includes(item) ? "strength" : "concern",
|
|
337
|
+
label: review.persona_id,
|
|
338
|
+
itemId: item.id,
|
|
339
|
+
reason: item.reason
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
document.getElementById("source-section").style.display = "";
|
|
346
|
+
document.getElementById("source-paragraphs").innerHTML = source.paragraphs.map((p) => {
|
|
347
|
+
const annotations = annotatedParagraphs.get(p.id) || [];
|
|
348
|
+
const annotationHtml = annotations.length ? `
|
|
349
|
+
<div class="paragraph-annotations">
|
|
350
|
+
${annotations.map(a => `
|
|
351
|
+
<span class="annotation ${a.kind}">
|
|
352
|
+
<span class="annotation-label">${esc(a.label)} ${esc(a.itemId)}</span>
|
|
353
|
+
<span class="annotation-text">${esc(a.reason)}</span>
|
|
354
|
+
</span>
|
|
355
|
+
`).join("")}
|
|
356
|
+
</div>
|
|
357
|
+
` : "";
|
|
358
|
+
return `
|
|
359
|
+
<div class="paragraph">
|
|
360
|
+
<div class="paragraph-num">Paragraph ${esc(String(p.id))}</div>
|
|
361
|
+
<p class="paragraph-text">${esc(p.text)}</p>
|
|
362
|
+
${annotationHtml}
|
|
363
|
+
</div>
|
|
364
|
+
`;
|
|
365
|
+
}).join("");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const rp = payload.revision_prompt;
|
|
369
|
+
if (rp?.found && rp.text) {
|
|
370
|
+
document.getElementById("revision-section").style.display = "";
|
|
371
|
+
document.getElementById("revision-prompt").textContent = rp.text;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Footer
|
|
375
|
+
var generatedDate = payload.generated_at || new Date().toISOString().slice(0, 10);
|
|
376
|
+
document.getElementById("report-footer").innerHTML =
|
|
377
|
+
'Ghost Reader — persona-driven fiction review · session ' +
|
|
378
|
+
esc(payload.session_id) + ' · generated ' + esc(generatedDate);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
render();
|
|
382
|
+
</script>
|
|
383
|
+
</body>
|
|
384
|
+
</html>
|