coaia-visualizer 1.6.1 → 1.6.2
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.
- package/README.md +1 -1
- package/STC.md +6 -0
- package/STCISSUE.md +12 -0
- package/app/page.tsx +4 -23
- package/components/chart-detail-editable.tsx +3 -0
- package/components/metadata-projections.tsx +208 -0
- package/lib/chart-editor.ts +13 -13
- package/lib/jsonl-parser.ts +55 -8
- package/lib/jsonl-preservation.ts +405 -0
- package/lib/types.ts +30 -0
- package/package.json +2 -1
- package/test-results/.last-run.json +4 -0
- package/test-scripts/rich-jsonl-preservation.spec.ts +118 -0
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ This tool allows users to upload their `coaia-narrative` memory files and gain c
|
|
|
17
17
|
* **Dynamic Structural Tension Display:** Clearly distinguishes and presents the core Desired Outcome and Current Reality observations, making the generative force of structural tension visible.
|
|
18
18
|
* **Action Step Tracking:** View all Action Steps for a selected chart, complete with their descriptions, completion status, and due dates, facilitating a clear understanding of strategic advancement.
|
|
19
19
|
* **Narrative Beat Integration:** Displays associated Narrative Beats, offering multi-universe perspectives (Engineer-World, Ceremony-World, Story-Engine-World) to enrich the context of your creative efforts.
|
|
20
|
+
* **Rich JSONL Metadata Projection:** Separates chart, action-step, and narrative-beat records; exposes nested `metadata.github`, RISE/spec fields, unknown extension metadata, and preservation warnings aligned with [`avadisabelle/coaia-narrative#35`](https://github.com/avadisabelle/coaia-narrative/issues/35).
|
|
20
21
|
* **Inter-Chart Relation Graph:** Visualizes the relationships between charts and entities, illustrating how different creative projects and their components are interconnected.
|
|
21
22
|
* **Data Statistics Overview:** Provides summary statistics of the loaded data, including total charts, entities, and relations, for quick comprehension of your creative landscape.
|
|
22
23
|
|
|
@@ -113,4 +114,3 @@ Continue building your app on:
|
|
|
113
114
|
## Alternative
|
|
114
115
|
* You can run it locally
|
|
115
116
|
|
|
116
|
-
|
package/STC.md
CHANGED
package/STCISSUE.md
CHANGED
|
@@ -34,3 +34,15 @@ Issue being tracked via @stcissue bot.
|
|
|
34
34
|
### Current Reality
|
|
35
35
|
Issue being tracked via @stcissue bot.
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
## Issue #20 - Render rich COAIA JSONL metadata with chart/action/beat projections
|
|
39
|
+
|
|
40
|
+
- **Repository**: jgwill/coaia-visualizer
|
|
41
|
+
- **State**: open
|
|
42
|
+
- **Category**: technical
|
|
43
|
+
- **Logged**: 2026-05-19T06:53:31Z
|
|
44
|
+
- **GitHub Link**: https://github.com/jgwill/coaia-visualizer/issues/20
|
|
45
|
+
|
|
46
|
+
### Current Reality
|
|
47
|
+
Issue being tracked via @stcissue bot.
|
|
48
|
+
|
package/app/page.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { DataStats } from "@/components/data-stats"
|
|
|
8
8
|
import { CreateChartForm } from "@/components/create-chart-form"
|
|
9
9
|
import type { ParsedData, Chart } from "@/lib/types"
|
|
10
10
|
import { parseJSONL, organizeData } from "@/lib/jsonl-parser"
|
|
11
|
+
import { serializeParsedData } from "@/lib/jsonl-preservation"
|
|
11
12
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
12
13
|
import { Download, Upload, RefreshCw, Save, Edit3, Plus } from "lucide-react"
|
|
13
14
|
import { Button } from "@/components/ui/button"
|
|
@@ -120,20 +121,10 @@ export default function Page() {
|
|
|
120
121
|
|
|
121
122
|
setIsLoading(true)
|
|
122
123
|
try {
|
|
123
|
-
const jsonlLines: string[] = []
|
|
124
|
-
|
|
125
|
-
for (const entity of data.entities.values()) {
|
|
126
|
-
jsonlLines.push(JSON.stringify(entity))
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
for (const relation of data.relations) {
|
|
130
|
-
jsonlLines.push(JSON.stringify(relation))
|
|
131
|
-
}
|
|
132
|
-
|
|
133
124
|
const response = await fetch("/api/jsonl", {
|
|
134
125
|
method: "POST",
|
|
135
126
|
headers: { "Content-Type": "application/json" },
|
|
136
|
-
body: JSON.stringify({ content:
|
|
127
|
+
body: JSON.stringify({ content: serializeParsedData(data) }),
|
|
137
128
|
})
|
|
138
129
|
|
|
139
130
|
if (response.ok) {
|
|
@@ -160,17 +151,7 @@ export default function Page() {
|
|
|
160
151
|
const handleExportData = () => {
|
|
161
152
|
if (!data) return
|
|
162
153
|
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
for (const entity of data.entities.values()) {
|
|
166
|
-
jsonlLines.push(JSON.stringify(entity))
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (const relation of data.relations) {
|
|
170
|
-
jsonlLines.push(JSON.stringify(relation))
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const blob = new Blob([jsonlLines.join("\n") + "\n"], { type: "application/jsonl" })
|
|
154
|
+
const blob = new Blob([serializeParsedData(data)], { type: "application/jsonl" })
|
|
174
155
|
const url = URL.createObjectURL(blob)
|
|
175
156
|
const a = document.createElement("a")
|
|
176
157
|
a.href = url
|
|
@@ -206,7 +187,7 @@ export default function Page() {
|
|
|
206
187
|
const previousChartId = newStack.pop()!.id
|
|
207
188
|
setChartNavigationStack(newStack)
|
|
208
189
|
// Find the refreshed parent chart from current data (with populated subCharts)
|
|
209
|
-
const refreshedParent = data
|
|
190
|
+
const refreshedParent = data?.charts.find((c) => c.id === previousChartId)
|
|
210
191
|
if (refreshedParent) {
|
|
211
192
|
setSelectedChart(refreshedParent)
|
|
212
193
|
}
|
|
@@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator"
|
|
|
9
9
|
import type { ParsedData, Chart } from "@/lib/types"
|
|
10
10
|
import { Target, MapPin, ListChecks, Network, BookOpen, TrendingUp, ArrowLeft } from "lucide-react"
|
|
11
11
|
import { NarrativeBeats } from "@/components/narrative-beats"
|
|
12
|
+
import { MetadataProjections } from "@/components/metadata-projections"
|
|
12
13
|
import { RelationGraph } from "@/components/relation-graph"
|
|
13
14
|
import { EditDesiredOutcome } from "./edit-desired-outcome"
|
|
14
15
|
import { EditCurrentReality } from "./edit-current-reality"
|
|
@@ -218,6 +219,8 @@ export function ChartDetailEditable({
|
|
|
218
219
|
</div>
|
|
219
220
|
)}
|
|
220
221
|
|
|
222
|
+
<MetadataProjections chart={chart} data={data} />
|
|
223
|
+
|
|
221
224
|
{/* Sub-charts */}
|
|
222
225
|
{chart.subCharts.length > 0 && (
|
|
223
226
|
<Card>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { AlertTriangle, Blocks, FileJson, Github, ListChecks, Sparkles, Target } from "lucide-react"
|
|
4
|
+
import { Badge } from "@/components/ui/badge"
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
6
|
+
import type { Chart, EntityRecord, ParsedData } from "@/lib/types"
|
|
7
|
+
|
|
8
|
+
interface MetadataProjectionsProps {
|
|
9
|
+
chart: Chart
|
|
10
|
+
data: ParsedData
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KNOWN_METADATA_KEYS = new Set([
|
|
14
|
+
"dueDate",
|
|
15
|
+
"chartId",
|
|
16
|
+
"phase",
|
|
17
|
+
"completionStatus",
|
|
18
|
+
"completedAt",
|
|
19
|
+
"parentChart",
|
|
20
|
+
"parentActionStep",
|
|
21
|
+
"level",
|
|
22
|
+
"createdAt",
|
|
23
|
+
"updatedAt",
|
|
24
|
+
"act",
|
|
25
|
+
"type_dramatic",
|
|
26
|
+
"universes",
|
|
27
|
+
"timestamp",
|
|
28
|
+
"elementsOfPerformance",
|
|
29
|
+
"mmotEvaluations",
|
|
30
|
+
"relationalAlignment",
|
|
31
|
+
"fourDirections",
|
|
32
|
+
"narrative",
|
|
33
|
+
"isTelescopedChart",
|
|
34
|
+
"telescopedChartId",
|
|
35
|
+
"github",
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
function JsonBlock({ value }: { value: unknown }) {
|
|
39
|
+
return (
|
|
40
|
+
<pre className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed">
|
|
41
|
+
{JSON.stringify(value, null, 2)}
|
|
42
|
+
</pre>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function ProjectionRecord({ entity, label }: { entity?: EntityRecord; label: string }) {
|
|
47
|
+
if (!entity) return null
|
|
48
|
+
|
|
49
|
+
const metadata = entity.metadata || {}
|
|
50
|
+
const unknownKeys = Object.keys(metadata).filter((key) => !KNOWN_METADATA_KEYS.has(key))
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="rounded-md border p-3">
|
|
54
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
55
|
+
<Badge variant="outline">{label}</Badge>
|
|
56
|
+
<span className="font-mono text-xs text-muted-foreground">{entity.name}</span>
|
|
57
|
+
{metadata.github && (
|
|
58
|
+
<Badge variant="secondary" className="gap-1">
|
|
59
|
+
<Github className="h-3 w-3" />
|
|
60
|
+
GitHub
|
|
61
|
+
</Badge>
|
|
62
|
+
)}
|
|
63
|
+
{unknownKeys.length > 0 && <Badge variant="secondary">{unknownKeys.length} extensions</Badge>}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{metadata.github && (
|
|
67
|
+
<div className="mt-3">
|
|
68
|
+
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
|
|
69
|
+
<Github className="h-4 w-4" />
|
|
70
|
+
GitHub Provenance
|
|
71
|
+
</div>
|
|
72
|
+
<JsonBlock value={metadata.github} />
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
<details className="mt-3">
|
|
77
|
+
<summary className="cursor-pointer text-sm font-medium">Full Metadata</summary>
|
|
78
|
+
<div className="mt-2">
|
|
79
|
+
<JsonBlock value={metadata} />
|
|
80
|
+
</div>
|
|
81
|
+
</details>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function MetadataProjections({ chart, data }: MetadataProjectionsProps) {
|
|
87
|
+
const riseSpec = (data.riseSpecs || []).find((spec) => spec.chartId === chart.id)
|
|
88
|
+
const chartRecordNames = new Set(
|
|
89
|
+
[
|
|
90
|
+
chart.chartEntity.name,
|
|
91
|
+
chart.desiredOutcome?.name,
|
|
92
|
+
chart.currentReality?.name,
|
|
93
|
+
...chart.actions.map((action) => action.name),
|
|
94
|
+
...chart.narrativeBeats.map((beat) => beat.name),
|
|
95
|
+
].filter(Boolean),
|
|
96
|
+
)
|
|
97
|
+
const warnings = (data.preservationWarnings || []).filter(
|
|
98
|
+
(warning) => !warning.recordName || chartRecordNames.has(warning.recordName),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="space-y-6">
|
|
103
|
+
{warnings.length > 0 && (
|
|
104
|
+
<Card className="border-amber-500/40">
|
|
105
|
+
<CardHeader>
|
|
106
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
107
|
+
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
|
108
|
+
Preservation Warnings
|
|
109
|
+
</CardTitle>
|
|
110
|
+
</CardHeader>
|
|
111
|
+
<CardContent className="space-y-2">
|
|
112
|
+
{warnings.map((warning, index) => (
|
|
113
|
+
<div key={`${warning.recordName}-${index}`} className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-sm">
|
|
114
|
+
<div className="font-medium">{warning.recordName || warning.recordType || "JSONL record"}</div>
|
|
115
|
+
<div className="text-muted-foreground">{warning.message}</div>
|
|
116
|
+
{warning.upstreamIssue && (
|
|
117
|
+
<a className="text-xs underline" href={warning.upstreamIssue} target="_blank" rel="noreferrer">
|
|
118
|
+
avadisabelle/coaia-narrative#35
|
|
119
|
+
</a>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</CardContent>
|
|
124
|
+
</Card>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<Card>
|
|
128
|
+
<CardHeader>
|
|
129
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
130
|
+
<Blocks className="h-5 w-5" />
|
|
131
|
+
RISE Projection
|
|
132
|
+
</CardTitle>
|
|
133
|
+
</CardHeader>
|
|
134
|
+
<CardContent className="grid gap-4 md:grid-cols-2">
|
|
135
|
+
<div>
|
|
136
|
+
<div className="mb-1 flex items-center gap-2 text-sm font-medium">
|
|
137
|
+
<Target className="h-4 w-4" />
|
|
138
|
+
Desired Outcome
|
|
139
|
+
</div>
|
|
140
|
+
<p className="text-sm text-muted-foreground">{riseSpec?.desiredOutcome || "No desired outcome"}</p>
|
|
141
|
+
</div>
|
|
142
|
+
<div>
|
|
143
|
+
<div className="mb-1 text-sm font-medium">Current Reality</div>
|
|
144
|
+
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
145
|
+
{(riseSpec?.currentReality.length ? riseSpec.currentReality : ["No current reality"]).map((item, index) => (
|
|
146
|
+
<li key={index}>{item}</li>
|
|
147
|
+
))}
|
|
148
|
+
</ul>
|
|
149
|
+
</div>
|
|
150
|
+
<div>
|
|
151
|
+
<div className="mb-1 text-sm font-medium">Natural Progression</div>
|
|
152
|
+
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
153
|
+
{(riseSpec?.naturalProgression.length ? riseSpec.naturalProgression : ["No projected progression"]).map((item, index) => (
|
|
154
|
+
<li key={index}>{item}</li>
|
|
155
|
+
))}
|
|
156
|
+
</ul>
|
|
157
|
+
</div>
|
|
158
|
+
<div>
|
|
159
|
+
<div className="mb-1 text-sm font-medium">Remaining Blockers</div>
|
|
160
|
+
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
161
|
+
{(riseSpec?.remainingBlockers.length ? riseSpec.remainingBlockers : ["No blockers recorded"]).map((item, index) => (
|
|
162
|
+
<li key={index}>{item}</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="md:col-span-2">
|
|
167
|
+
<div className="mb-1 text-sm font-medium">Next Chart Operations</div>
|
|
168
|
+
<ul className="space-y-1 text-sm text-muted-foreground">
|
|
169
|
+
{(riseSpec?.nextOperations.length ? riseSpec.nextOperations : ["No next operations recorded"]).map((item, index) => (
|
|
170
|
+
<li key={index}>{item}</li>
|
|
171
|
+
))}
|
|
172
|
+
</ul>
|
|
173
|
+
</div>
|
|
174
|
+
</CardContent>
|
|
175
|
+
</Card>
|
|
176
|
+
|
|
177
|
+
<Card>
|
|
178
|
+
<CardHeader>
|
|
179
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
180
|
+
<FileJson className="h-5 w-5" />
|
|
181
|
+
Rich Metadata Projection
|
|
182
|
+
</CardTitle>
|
|
183
|
+
</CardHeader>
|
|
184
|
+
<CardContent className="space-y-3">
|
|
185
|
+
<ProjectionRecord label="Chart" entity={chart.chartEntity} />
|
|
186
|
+
<ProjectionRecord label="Desired Outcome" entity={chart.desiredOutcome} />
|
|
187
|
+
<ProjectionRecord label="Current Reality" entity={chart.currentReality} />
|
|
188
|
+
{chart.actions.map((action) => (
|
|
189
|
+
<ProjectionRecord key={action.name} label="Action Step" entity={action} />
|
|
190
|
+
))}
|
|
191
|
+
{chart.narrativeBeats.map((beat) => (
|
|
192
|
+
<ProjectionRecord key={beat.name} label="Narrative Beat" entity={beat} />
|
|
193
|
+
))}
|
|
194
|
+
<div className="flex flex-wrap gap-2 pt-1 text-sm text-muted-foreground">
|
|
195
|
+
<Badge variant="outline" className="gap-1">
|
|
196
|
+
<ListChecks className="h-3 w-3" />
|
|
197
|
+
{data.projections?.actionStepRecords.length || 0} actions
|
|
198
|
+
</Badge>
|
|
199
|
+
<Badge variant="outline" className="gap-1">
|
|
200
|
+
<Sparkles className="h-3 w-3" />
|
|
201
|
+
{data.projections?.narrativeBeatRecords.length || 0} beats
|
|
202
|
+
</Badge>
|
|
203
|
+
</div>
|
|
204
|
+
</CardContent>
|
|
205
|
+
</Card>
|
|
206
|
+
</div>
|
|
207
|
+
)
|
|
208
|
+
}
|
package/lib/chart-editor.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// lib/chart-editor.ts - Chart editing logic
|
|
2
2
|
|
|
3
3
|
import type { EntityRecord, RelationRecord, ParsedData } from "./types"
|
|
4
|
+
import { buildMetadataWarnings, buildRecordProjections, serializeParsedData } from "./jsonl-preservation"
|
|
5
|
+
import { buildRiseSpecs } from "./jsonl-parser"
|
|
4
6
|
|
|
5
7
|
export interface ChartUpdate {
|
|
6
8
|
type:
|
|
@@ -20,11 +22,13 @@ export interface ChartUpdate {
|
|
|
20
22
|
export class ChartEditor {
|
|
21
23
|
private entities: Map<string, EntityRecord>
|
|
22
24
|
private relations: RelationRecord[]
|
|
25
|
+
private rawRecords: NonNullable<ParsedData["rawRecords"]>
|
|
23
26
|
|
|
24
27
|
constructor(parsedData: ParsedData) {
|
|
25
28
|
// Clone data to avoid mutations
|
|
26
29
|
this.entities = new Map(parsedData.entities)
|
|
27
30
|
this.relations = [...parsedData.relations]
|
|
31
|
+
this.rawRecords = [...(parsedData.rawRecords || [])]
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
private resolveStructuralChart(chartRef: string): { entityName: string; chartId: string; entity: EntityRecord } | null {
|
|
@@ -520,19 +524,11 @@ export class ChartEditor {
|
|
|
520
524
|
}
|
|
521
525
|
|
|
522
526
|
exportToJSONL(): string {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Export relations
|
|
531
|
-
for (const relation of this.relations) {
|
|
532
|
-
lines.push(JSON.stringify(relation))
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
return lines.join("\n") + "\n"
|
|
527
|
+
return serializeParsedData({
|
|
528
|
+
entities: this.entities,
|
|
529
|
+
relations: this.relations,
|
|
530
|
+
rawRecords: this.rawRecords,
|
|
531
|
+
})
|
|
536
532
|
}
|
|
537
533
|
|
|
538
534
|
getUpdatedData(): ParsedData {
|
|
@@ -615,6 +611,10 @@ export class ChartEditor {
|
|
|
615
611
|
relations: this.relations,
|
|
616
612
|
charts: chartsArray,
|
|
617
613
|
rootCharts: rootCharts,
|
|
614
|
+
rawRecords: this.rawRecords,
|
|
615
|
+
projections: buildRecordProjections(this.entities),
|
|
616
|
+
preservationWarnings: buildMetadataWarnings(this.rawRecords),
|
|
617
|
+
riseSpecs: buildRiseSpecs(chartsArray),
|
|
618
618
|
}
|
|
619
619
|
}
|
|
620
620
|
}
|
package/lib/jsonl-parser.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Chart, EntityRecord, JSONLRecord, ParsedData, RawJSONLRecord, RelationRecord, RiseSpecProjection } from "./types"
|
|
2
|
+
import { buildMetadataWarnings, buildRecordProjections, normalizeJsonlRecord } from "./jsonl-preservation"
|
|
2
3
|
|
|
3
|
-
export function parseJSONL(content: string):
|
|
4
|
+
export function parseJSONL(content: string): RawJSONLRecord[] {
|
|
4
5
|
const lines = content.trim().split("\n")
|
|
5
|
-
const records:
|
|
6
|
+
const records: RawJSONLRecord[] = []
|
|
6
7
|
|
|
7
8
|
for (const line of lines) {
|
|
8
9
|
if (!line.trim()) continue
|
|
@@ -17,7 +18,44 @@ export function parseJSONL(content: string): JSONLRecord[] {
|
|
|
17
18
|
return records
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
function firstObservation(entity?: EntityRecord): string {
|
|
22
|
+
return entity?.observations?.[0] || ""
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function asStringList(value: unknown): string[] {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
return value.map((item) => typeof item === "string" ? item : JSON.stringify(item))
|
|
28
|
+
}
|
|
29
|
+
if (typeof value === "string" && value.trim()) {
|
|
30
|
+
return [value]
|
|
31
|
+
}
|
|
32
|
+
return []
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildRiseSpecs(charts: Chart[]): RiseSpecProjection[] {
|
|
36
|
+
return charts.map((chart) => {
|
|
37
|
+
const metadata = chart.chartEntity.metadata || {}
|
|
38
|
+
const rise = metadata.rise || metadata.riseSpec || metadata.spec || {}
|
|
39
|
+
const incompleteActions = chart.actions.filter((action) => action.metadata?.completionStatus !== true)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
chartId: chart.id,
|
|
43
|
+
desiredOutcome: firstObservation(chart.desiredOutcome),
|
|
44
|
+
currentReality: chart.currentReality?.observations || [],
|
|
45
|
+
naturalProgression: [
|
|
46
|
+
...asStringList(rise.naturalProgression || metadata.naturalProgression),
|
|
47
|
+
...incompleteActions.slice(0, 3).map((action) => action.observations?.[0]).filter(Boolean),
|
|
48
|
+
],
|
|
49
|
+
remainingBlockers: asStringList(rise.remainingBlockers || rise.blockers || metadata.remainingBlockers || metadata.blockers),
|
|
50
|
+
nextOperations: [
|
|
51
|
+
...asStringList(rise.nextOperations || metadata.nextOperations),
|
|
52
|
+
...incompleteActions.slice(0, 5).map((action) => action.observations?.[0]).filter(Boolean),
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function organizeData(records: RawJSONLRecord[]): ParsedData {
|
|
21
59
|
const entities = new Map<string, EntityRecord>()
|
|
22
60
|
const relations: RelationRecord[] = []
|
|
23
61
|
const charts = new Map<string, Chart>()
|
|
@@ -26,10 +64,15 @@ export function organizeData(records: JSONLRecord[]): ParsedData {
|
|
|
26
64
|
|
|
27
65
|
// First pass: separate entities and relations
|
|
28
66
|
for (const record of records) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
67
|
+
const normalized = normalizeJsonlRecord(record)
|
|
68
|
+
if (!normalized) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (normalized.type === "entity") {
|
|
73
|
+
entities.set(normalized.name, normalized)
|
|
74
|
+
} else if (normalized.type === "relation") {
|
|
75
|
+
relations.push(normalized)
|
|
33
76
|
}
|
|
34
77
|
}
|
|
35
78
|
|
|
@@ -163,6 +206,10 @@ export function organizeData(records: JSONLRecord[]): ParsedData {
|
|
|
163
206
|
entities,
|
|
164
207
|
relations,
|
|
165
208
|
rootCharts,
|
|
209
|
+
rawRecords: records,
|
|
210
|
+
projections: buildRecordProjections(entities),
|
|
211
|
+
preservationWarnings: buildMetadataWarnings(records),
|
|
212
|
+
riseSpecs: buildRiseSpecs(Array.from(charts.values())),
|
|
166
213
|
}
|
|
167
214
|
}
|
|
168
215
|
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntityRecord,
|
|
3
|
+
MetadataWarning,
|
|
4
|
+
ParsedData,
|
|
5
|
+
RawJSONLRecord,
|
|
6
|
+
RecordProjections,
|
|
7
|
+
RelationRecord,
|
|
8
|
+
} from "./types"
|
|
9
|
+
|
|
10
|
+
const UPSTREAM_ISSUE = "https://github.com/avadisabelle/coaia-narrative/issues/35"
|
|
11
|
+
|
|
12
|
+
const ENTITY_TOP_LEVEL_KEYS = new Set(["type", "name", "entityType", "observations", "metadata"])
|
|
13
|
+
const RELATION_TOP_LEVEL_KEYS = new Set(["type", "from", "to", "relationType", "metadata"])
|
|
14
|
+
const NARRATIVE_BEAT_TOP_LEVEL_KEYS = new Set([
|
|
15
|
+
...ENTITY_TOP_LEVEL_KEYS,
|
|
16
|
+
"narrative",
|
|
17
|
+
"relational_alignment",
|
|
18
|
+
"four_directions",
|
|
19
|
+
])
|
|
20
|
+
const MUTABLE_METADATA_KEYS = new Set([
|
|
21
|
+
"dueDate",
|
|
22
|
+
"chartId",
|
|
23
|
+
"phase",
|
|
24
|
+
"completionStatus",
|
|
25
|
+
"completedAt",
|
|
26
|
+
"parentChart",
|
|
27
|
+
"parentActionStep",
|
|
28
|
+
"level",
|
|
29
|
+
"createdAt",
|
|
30
|
+
"updatedAt",
|
|
31
|
+
"act",
|
|
32
|
+
"type_dramatic",
|
|
33
|
+
"universes",
|
|
34
|
+
"timestamp",
|
|
35
|
+
"elementsOfPerformance",
|
|
36
|
+
"mmotEvaluations",
|
|
37
|
+
"relationalAlignment",
|
|
38
|
+
"fourDirections",
|
|
39
|
+
"narrative",
|
|
40
|
+
"isTelescopedChart",
|
|
41
|
+
"telescopedChartId",
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
function isObject(value: unknown): value is Record<string, any> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneJson<T>(value: T): T {
|
|
49
|
+
return value === undefined ? value : JSON.parse(JSON.stringify(value))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function deepEqual(left: unknown, right: unknown): boolean {
|
|
53
|
+
return JSON.stringify(left) === JSON.stringify(right)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function removeUndefined<T extends Record<string, any>>(record: T): T {
|
|
57
|
+
for (const key of Object.keys(record)) {
|
|
58
|
+
if (record[key] === undefined) {
|
|
59
|
+
delete record[key]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return record
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function relationKey(relation: Pick<RelationRecord, "from" | "to" | "relationType">): string {
|
|
66
|
+
return `${relation.from}\u0000${relation.to}\u0000${relation.relationType}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isEntityLikeRecord(record: RawJSONLRecord): boolean {
|
|
70
|
+
return (record.type === "entity" || record.type === "narrative_beat") && typeof record.name === "string"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isRelationLikeRecord(record: RawJSONLRecord): boolean {
|
|
74
|
+
return (
|
|
75
|
+
record.type === "relation" &&
|
|
76
|
+
typeof record.from === "string" &&
|
|
77
|
+
typeof record.to === "string" &&
|
|
78
|
+
typeof record.relationType === "string"
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeJsonlRecord(record: RawJSONLRecord): EntityRecord | RelationRecord | null {
|
|
83
|
+
if (record.type === "entity" && typeof record.name === "string") {
|
|
84
|
+
return {
|
|
85
|
+
...record,
|
|
86
|
+
type: "entity",
|
|
87
|
+
observations: Array.isArray(record.observations) ? record.observations : [],
|
|
88
|
+
metadata: isObject(record.metadata) ? record.metadata : {},
|
|
89
|
+
} as EntityRecord
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (record.type === "narrative_beat" && typeof record.name === "string") {
|
|
93
|
+
const metadata = {
|
|
94
|
+
...(isObject(record.metadata) ? record.metadata : {}),
|
|
95
|
+
narrative: isObject(record.metadata) && record.metadata.narrative !== undefined
|
|
96
|
+
? record.metadata.narrative
|
|
97
|
+
: record.narrative,
|
|
98
|
+
relationalAlignment: isObject(record.metadata) && record.metadata.relationalAlignment !== undefined
|
|
99
|
+
? record.metadata.relationalAlignment
|
|
100
|
+
: record.relational_alignment,
|
|
101
|
+
fourDirections: isObject(record.metadata) && record.metadata.fourDirections !== undefined
|
|
102
|
+
? record.metadata.fourDirections
|
|
103
|
+
: record.four_directions,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return removeUndefined({
|
|
107
|
+
...record,
|
|
108
|
+
type: "entity",
|
|
109
|
+
name: record.name as string,
|
|
110
|
+
entityType: "narrative_beat",
|
|
111
|
+
observations: Array.isArray(record.observations) ? record.observations : [],
|
|
112
|
+
metadata: removeUndefined(metadata),
|
|
113
|
+
}) as EntityRecord
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isRelationLikeRecord(record)) {
|
|
117
|
+
return {
|
|
118
|
+
...record,
|
|
119
|
+
type: "relation",
|
|
120
|
+
metadata: isObject(record.metadata) ? record.metadata : {},
|
|
121
|
+
} as RelationRecord
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function mergePreservedMetadata(originalMetadata: unknown, currentMetadata: unknown): Record<string, any> {
|
|
128
|
+
const merged = {
|
|
129
|
+
...(isObject(currentMetadata) ? currentMetadata : {}),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isObject(originalMetadata)) {
|
|
133
|
+
for (const [key, value] of Object.entries(originalMetadata)) {
|
|
134
|
+
if (!(key in merged) && (!MUTABLE_METADATA_KEYS.has(key) || key === "github")) {
|
|
135
|
+
merged[key] = cloneJson(value)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return merged
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function serializeEntity(entity: EntityRecord, original?: RawJSONLRecord): RawJSONLRecord {
|
|
144
|
+
const metadata = mergePreservedMetadata(original?.metadata, entity.metadata)
|
|
145
|
+
|
|
146
|
+
if (original?.type === "narrative_beat") {
|
|
147
|
+
const record: RawJSONLRecord = {
|
|
148
|
+
...original,
|
|
149
|
+
type: "narrative_beat",
|
|
150
|
+
name: entity.name,
|
|
151
|
+
observations: entity.observations,
|
|
152
|
+
metadata,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if ("entityType" in original) {
|
|
156
|
+
record.entityType = entity.entityType
|
|
157
|
+
}
|
|
158
|
+
if ("narrative" in original && entity.metadata?.narrative !== undefined) {
|
|
159
|
+
record.narrative = cloneJson(entity.metadata.narrative)
|
|
160
|
+
}
|
|
161
|
+
if ("relational_alignment" in original && entity.metadata?.relationalAlignment !== undefined) {
|
|
162
|
+
record.relational_alignment = cloneJson(entity.metadata.relationalAlignment)
|
|
163
|
+
}
|
|
164
|
+
if ("four_directions" in original && entity.metadata?.fourDirections !== undefined) {
|
|
165
|
+
record.four_directions = cloneJson(entity.metadata.fourDirections)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return removeUndefined(record)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return removeUndefined({
|
|
172
|
+
...original,
|
|
173
|
+
...entity,
|
|
174
|
+
type: "entity",
|
|
175
|
+
metadata,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function serializeRelation(relation: RelationRecord, original?: RawJSONLRecord): RawJSONLRecord {
|
|
180
|
+
return removeUndefined({
|
|
181
|
+
...original,
|
|
182
|
+
...relation,
|
|
183
|
+
type: "relation",
|
|
184
|
+
metadata: mergePreservedMetadata(original?.metadata, relation.metadata),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function findRecord(records: RawJSONLRecord[], key: string): RawJSONLRecord | undefined {
|
|
189
|
+
return records.find((record) => {
|
|
190
|
+
if (isEntityLikeRecord(record) && record.name === key) return true
|
|
191
|
+
if (isRelationLikeRecord(record)) return relationKey(record as RelationRecord) === key
|
|
192
|
+
return false
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function validateMetadata(label: string, beforeMetadata: unknown, afterMetadata: unknown, failures: string[]): void {
|
|
197
|
+
if (!isObject(beforeMetadata)) return
|
|
198
|
+
if (!isObject(afterMetadata)) {
|
|
199
|
+
failures.push(`${label}: metadata object missing after write`)
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const [key, value] of Object.entries(beforeMetadata)) {
|
|
204
|
+
if (!(key in afterMetadata)) {
|
|
205
|
+
failures.push(`${label}: metadata.${key} missing after write`)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if ((key === "github" || !MUTABLE_METADATA_KEYS.has(key)) && !deepEqual(value, afterMetadata[key])) {
|
|
210
|
+
failures.push(`${label}: metadata.${key} changed unexpectedly`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function validateRecordPreservation(
|
|
216
|
+
beforeRecords: RawJSONLRecord[],
|
|
217
|
+
afterRecords: RawJSONLRecord[],
|
|
218
|
+
entities: Map<string, EntityRecord>,
|
|
219
|
+
relations: RelationRecord[],
|
|
220
|
+
): void {
|
|
221
|
+
const relationKeys = new Set(relations.map((relation) => relationKey(relation)))
|
|
222
|
+
const failures: string[] = []
|
|
223
|
+
|
|
224
|
+
for (const before of beforeRecords) {
|
|
225
|
+
if (isEntityLikeRecord(before)) {
|
|
226
|
+
const name = before.name as string
|
|
227
|
+
if (!entities.has(name)) continue
|
|
228
|
+
|
|
229
|
+
const after = findRecord(afterRecords, name)
|
|
230
|
+
if (!after) {
|
|
231
|
+
failures.push(`${name}: record missing after write`)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (before.type === "narrative_beat" && after.type !== "narrative_beat") {
|
|
236
|
+
failures.push(`${name}: legacy narrative_beat record type was flattened`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const knownKeys = before.type === "narrative_beat" ? NARRATIVE_BEAT_TOP_LEVEL_KEYS : ENTITY_TOP_LEVEL_KEYS
|
|
240
|
+
for (const key of Object.keys(before)) {
|
|
241
|
+
if (!knownKeys.has(key) && !deepEqual(before[key], after[key])) {
|
|
242
|
+
failures.push(`${name}: top-level extension field "${key}" was not preserved`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
validateMetadata(name, before.metadata, after.metadata, failures)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (isRelationLikeRecord(before)) {
|
|
249
|
+
const key = relationKey(before as RelationRecord)
|
|
250
|
+
if (!relationKeys.has(key)) continue
|
|
251
|
+
|
|
252
|
+
const after = findRecord(afterRecords, key)
|
|
253
|
+
if (!after) {
|
|
254
|
+
failures.push(`${key}: relation missing after write`)
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const topLevelKey of Object.keys(before)) {
|
|
259
|
+
if (!RELATION_TOP_LEVEL_KEYS.has(topLevelKey) && !deepEqual(before[topLevelKey], after[topLevelKey])) {
|
|
260
|
+
failures.push(`${key}: relation extension field "${topLevelKey}" was not preserved`)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
validateMetadata(key, before.metadata, after.metadata, failures)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (failures.length > 0) {
|
|
268
|
+
throw new Error(`COAIA JSONL metadata preservation failed:\n- ${failures.join("\n- ")}`)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function buildRecordProjections(entities: Map<string, EntityRecord>): RecordProjections {
|
|
273
|
+
const allEntities = Array.from(entities.values())
|
|
274
|
+
return {
|
|
275
|
+
chartRecords: allEntities.filter((entity) => entity.entityType === "structural_tension_chart"),
|
|
276
|
+
actionStepRecords: allEntities.filter((entity) => entity.entityType === "action_step" || entity.metadata?.isTelescopedChart),
|
|
277
|
+
narrativeBeatRecords: allEntities.filter((entity) => entity.entityType === "narrative_beat"),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function buildMetadataWarnings(records: RawJSONLRecord[]): MetadataWarning[] {
|
|
282
|
+
const warnings: MetadataWarning[] = []
|
|
283
|
+
|
|
284
|
+
for (const record of records) {
|
|
285
|
+
if (!isEntityLikeRecord(record) && !isRelationLikeRecord(record)) continue
|
|
286
|
+
|
|
287
|
+
const recordName = record.name || [record.from, record.to, record.relationType].filter(Boolean).join(" -> ")
|
|
288
|
+
const metadata = record.metadata
|
|
289
|
+
|
|
290
|
+
if (!isObject(metadata)) {
|
|
291
|
+
warnings.push({
|
|
292
|
+
severity: "warning",
|
|
293
|
+
recordName,
|
|
294
|
+
recordType: record.type,
|
|
295
|
+
message: "Record has no metadata object; provenance and extension fields may already be flattened.",
|
|
296
|
+
upstreamIssue: UPSTREAM_ISSUE,
|
|
297
|
+
})
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (record.github && !metadata.github) {
|
|
302
|
+
warnings.push({
|
|
303
|
+
severity: "warning",
|
|
304
|
+
recordName,
|
|
305
|
+
recordType: record.type,
|
|
306
|
+
message: "GitHub provenance exists outside metadata.github; this shape is suspicious for preservation-aware tools.",
|
|
307
|
+
upstreamIssue: UPSTREAM_ISSUE,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const normalized = normalizeJsonlRecord(record)
|
|
312
|
+
if (normalized && "entityType" in normalized) {
|
|
313
|
+
if (
|
|
314
|
+
["structural_tension_chart", "action_step", "desired_outcome", "current_reality", "narrative_beat"].includes(
|
|
315
|
+
normalized.entityType,
|
|
316
|
+
) &&
|
|
317
|
+
!normalized.metadata?.chartId
|
|
318
|
+
) {
|
|
319
|
+
warnings.push({
|
|
320
|
+
severity: "warning",
|
|
321
|
+
recordName: normalized.name,
|
|
322
|
+
recordType: normalized.entityType,
|
|
323
|
+
message: "COAIA entity is missing metadata.chartId, so chart/action projection may be incomplete.",
|
|
324
|
+
upstreamIssue: UPSTREAM_ISSUE,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (
|
|
329
|
+
normalized.entityType === "narrative_beat" &&
|
|
330
|
+
!normalized.metadata?.narrative &&
|
|
331
|
+
!record.narrative &&
|
|
332
|
+
normalized.observations.length <= 1
|
|
333
|
+
) {
|
|
334
|
+
warnings.push({
|
|
335
|
+
severity: "warning",
|
|
336
|
+
recordName: normalized.name,
|
|
337
|
+
recordType: normalized.entityType,
|
|
338
|
+
message: "Narrative beat appears flattened: no narrative metadata or rich beat payload is visible.",
|
|
339
|
+
upstreamIssue: UPSTREAM_ISSUE,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (record.type === "narrative_beat") {
|
|
345
|
+
warnings.push({
|
|
346
|
+
severity: "warning",
|
|
347
|
+
recordName,
|
|
348
|
+
recordType: "narrative_beat",
|
|
349
|
+
message: "Legacy narrative_beat shape detected. It is rendered separately and preserved on save.",
|
|
350
|
+
upstreamIssue: UPSTREAM_ISSUE,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return warnings
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function serializeParsedData(data: Pick<ParsedData, "entities" | "relations" | "rawRecords">): string {
|
|
359
|
+
const rawRecords = data.rawRecords || []
|
|
360
|
+
const emittedEntities = new Set<string>()
|
|
361
|
+
const emittedRelations = new Set<string>()
|
|
362
|
+
const records: RawJSONLRecord[] = []
|
|
363
|
+
|
|
364
|
+
for (const original of rawRecords) {
|
|
365
|
+
if (isEntityLikeRecord(original)) {
|
|
366
|
+
const entity = data.entities.get(original.name)
|
|
367
|
+
if (entity) {
|
|
368
|
+
records.push(serializeEntity(entity, original))
|
|
369
|
+
emittedEntities.add(entity.name)
|
|
370
|
+
}
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (isRelationLikeRecord(original)) {
|
|
375
|
+
const key = relationKey(original as RelationRecord)
|
|
376
|
+
const relation = data.relations.find((candidate) => relationKey(candidate) === key)
|
|
377
|
+
if (relation) {
|
|
378
|
+
records.push(serializeRelation(relation, original))
|
|
379
|
+
emittedRelations.add(key)
|
|
380
|
+
}
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
records.push(original)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
for (const entity of data.entities.values()) {
|
|
388
|
+
if (!emittedEntities.has(entity.name)) {
|
|
389
|
+
records.push(serializeEntity(entity))
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const relation of data.relations) {
|
|
394
|
+
const key = relationKey(relation)
|
|
395
|
+
if (!emittedRelations.has(key)) {
|
|
396
|
+
records.push(serializeRelation(relation))
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
validateRecordPreservation(rawRecords, records, data.entities, data.relations)
|
|
401
|
+
|
|
402
|
+
return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export { UPSTREAM_ISSUE }
|
package/lib/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface EntityRecord {
|
|
|
6
6
|
entityType: "structural_tension_chart" | "desired_outcome" | "current_reality" | "action_step" | "narrative_beat"
|
|
7
7
|
observations: string[]
|
|
8
8
|
metadata: Record<string, any>
|
|
9
|
+
[key: string]: any
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export interface RelationRecord {
|
|
@@ -14,9 +15,34 @@ export interface RelationRecord {
|
|
|
14
15
|
to: string
|
|
15
16
|
relationType: "contains" | "creates_tension_with" | "advances_toward" | "documents"
|
|
16
17
|
metadata: Record<string, any>
|
|
18
|
+
[key: string]: any
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export type JSONLRecord = EntityRecord | RelationRecord
|
|
22
|
+
export type RawJSONLRecord = Record<string, any>
|
|
23
|
+
|
|
24
|
+
export interface MetadataWarning {
|
|
25
|
+
severity: "warning" | "error"
|
|
26
|
+
recordName?: string
|
|
27
|
+
recordType?: string
|
|
28
|
+
message: string
|
|
29
|
+
upstreamIssue?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RecordProjections {
|
|
33
|
+
chartRecords: EntityRecord[]
|
|
34
|
+
actionStepRecords: EntityRecord[]
|
|
35
|
+
narrativeBeatRecords: EntityRecord[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RiseSpecProjection {
|
|
39
|
+
chartId: string
|
|
40
|
+
desiredOutcome: string
|
|
41
|
+
currentReality: string[]
|
|
42
|
+
naturalProgression: string[]
|
|
43
|
+
remainingBlockers: string[]
|
|
44
|
+
nextOperations: string[]
|
|
45
|
+
}
|
|
20
46
|
|
|
21
47
|
export interface Chart {
|
|
22
48
|
id: string
|
|
@@ -36,4 +62,8 @@ export interface ParsedData {
|
|
|
36
62
|
entities: Map<string, EntityRecord>
|
|
37
63
|
relations: RelationRecord[]
|
|
38
64
|
rootCharts: Chart[]
|
|
65
|
+
rawRecords?: RawJSONLRecord[]
|
|
66
|
+
projections?: RecordProjections
|
|
67
|
+
preservationWarnings?: MetadataWarning[]
|
|
68
|
+
riseSpecs?: RiseSpecProjection[]
|
|
39
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coaia-visualizer",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.tsx",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"prepare": "npm run build:cli",
|
|
27
27
|
"start": "next start",
|
|
28
28
|
"test": "playwright test",
|
|
29
|
+
"test:rich-jsonl": "playwright test test-scripts/rich-jsonl-preservation.spec.ts",
|
|
29
30
|
"test:global-cli": "playwright test test-scripts/test-global-cli.spec.ts",
|
|
30
31
|
"docker:build": "docker build -t jgwill/coaia:visualizer .",
|
|
31
32
|
"docker:push": "docker push jgwill/coaia:visualizer",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test, expect } from "@playwright/test"
|
|
2
|
+
import { organizeData, parseJSONL } from "../lib/jsonl-parser"
|
|
3
|
+
import { serializeParsedData } from "../lib/jsonl-preservation"
|
|
4
|
+
import { ChartEditor } from "../lib/chart-editor"
|
|
5
|
+
|
|
6
|
+
const fixture = [
|
|
7
|
+
{
|
|
8
|
+
type: "entity",
|
|
9
|
+
name: "chart_preserve_chart",
|
|
10
|
+
entityType: "structural_tension_chart",
|
|
11
|
+
observations: ["Chart fixture"],
|
|
12
|
+
metadata: {
|
|
13
|
+
chartId: "chart_preserve",
|
|
14
|
+
dueDate: "2026-12-31T00:00:00.000Z",
|
|
15
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
16
|
+
rise: {
|
|
17
|
+
naturalProgression: ["Render projections"],
|
|
18
|
+
remainingBlockers: ["No metadata panel"],
|
|
19
|
+
nextOperations: ["Inspect beat metadata"],
|
|
20
|
+
},
|
|
21
|
+
runtime: { agent: "coaia-agent" },
|
|
22
|
+
},
|
|
23
|
+
topLevelExtension: { source: "fixture-chart" },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "entity",
|
|
27
|
+
name: "chart_preserve_desired_outcome",
|
|
28
|
+
entityType: "desired_outcome",
|
|
29
|
+
observations: ["Render rich metadata"],
|
|
30
|
+
metadata: {
|
|
31
|
+
chartId: "chart_preserve",
|
|
32
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "entity",
|
|
37
|
+
name: "chart_preserve_current_reality",
|
|
38
|
+
entityType: "current_reality",
|
|
39
|
+
observations: ["Visualizer can flatten records"],
|
|
40
|
+
metadata: {
|
|
41
|
+
chartId: "chart_preserve",
|
|
42
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
43
|
+
provenance: { source: "coaia-agent" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "entity",
|
|
48
|
+
name: "chart_preserve_action_1",
|
|
49
|
+
entityType: "action_step",
|
|
50
|
+
observations: ["Expose metadata"],
|
|
51
|
+
metadata: {
|
|
52
|
+
chartId: "chart_preserve",
|
|
53
|
+
completionStatus: false,
|
|
54
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
55
|
+
unknownActionMetadata: { survives: true },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "narrative_beat",
|
|
60
|
+
name: "chart_preserve_beat_1",
|
|
61
|
+
observations: ["Act 1 setup"],
|
|
62
|
+
metadata: {
|
|
63
|
+
chartId: "chart_preserve",
|
|
64
|
+
act: 1,
|
|
65
|
+
type_dramatic: "setup",
|
|
66
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
67
|
+
beatSpecific: { nested: { survives: true } },
|
|
68
|
+
},
|
|
69
|
+
narrative: {
|
|
70
|
+
description: "Legacy beat payload",
|
|
71
|
+
prose: "The beat remains inspectable.",
|
|
72
|
+
lessons: ["Show the nested fields"],
|
|
73
|
+
},
|
|
74
|
+
topLevelExtension: { source: "legacy-beat" },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
type: "relation",
|
|
78
|
+
from: "chart_preserve_beat_1",
|
|
79
|
+
to: "chart_preserve_chart",
|
|
80
|
+
relationType: "documents",
|
|
81
|
+
metadata: {
|
|
82
|
+
github: { owner: "jgwill", repo: "coaia-visualizer", issue: 20 },
|
|
83
|
+
relationExtension: { survives: true },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
test("projects and preserves mixed COAIA JSONL metadata", () => {
|
|
89
|
+
const content = `${fixture.map((record) => JSON.stringify(record)).join("\n")}\n`
|
|
90
|
+
const parsed = organizeData(parseJSONL(content))
|
|
91
|
+
|
|
92
|
+
expect(parsed.projections!.chartRecords).toHaveLength(1)
|
|
93
|
+
expect(parsed.projections!.actionStepRecords).toHaveLength(1)
|
|
94
|
+
expect(parsed.projections!.narrativeBeatRecords).toHaveLength(1)
|
|
95
|
+
expect(parsed.preservationWarnings!.some((warning) => warning.upstreamIssue?.includes("coaia-narrative/issues/35"))).toBe(true)
|
|
96
|
+
expect(parsed.riseSpecs![0]).toMatchObject({
|
|
97
|
+
desiredOutcome: "Render rich metadata",
|
|
98
|
+
remainingBlockers: ["No metadata panel"],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const editor = new ChartEditor(parsed)
|
|
102
|
+
editor.updateActionProgress("chart_preserve_action_1", "Progress remains rich", false)
|
|
103
|
+
const exported = serializeParsedData(editor.getUpdatedData())
|
|
104
|
+
const records = exported.trim().split("\n").map((line) => JSON.parse(line))
|
|
105
|
+
const chart = records.find((record) => record.name === "chart_preserve_chart")
|
|
106
|
+
const action = records.find((record) => record.name === "chart_preserve_action_1")
|
|
107
|
+
const beat = records.find((record) => record.name === "chart_preserve_beat_1")
|
|
108
|
+
const relation = records.find((record) => record.type === "relation")
|
|
109
|
+
|
|
110
|
+
expect(chart.metadata.github.issue).toBe(20)
|
|
111
|
+
expect(chart.metadata.runtime.agent).toBe("coaia-agent")
|
|
112
|
+
expect(chart.topLevelExtension.source).toBe("fixture-chart")
|
|
113
|
+
expect(action.metadata.unknownActionMetadata.survives).toBe(true)
|
|
114
|
+
expect(beat.type).toBe("narrative_beat")
|
|
115
|
+
expect(beat.metadata.beatSpecific.nested.survives).toBe(true)
|
|
116
|
+
expect(beat.narrative.lessons[0]).toBe("Show the nested fields")
|
|
117
|
+
expect(relation.metadata.relationExtension.survives).toBe(true)
|
|
118
|
+
})
|