Keep generated prose out of Markdown structure
This commit is contained in:
@@ -70,5 +70,7 @@ go test ./internal/generatedtext
|
||||
```
|
||||
|
||||
Generated text supplies prose slots only; deterministic weather facts remain in
|
||||
module and fact values. Every report definition must resolve to exactly one
|
||||
supported catalog pair.
|
||||
module and fact values. The renderer applies its plain-text policy to every
|
||||
generated prose insertion, preserving ordinary text and paragraph breaks while
|
||||
preventing provider text from creating Markdown or HTML structure. Every report
|
||||
definition must resolve to exactly one supported catalog pair.
|
||||
|
||||
@@ -30,7 +30,11 @@ partials cover daypart forecast variants, alert digest, and precipitation
|
||||
timing. Template code receives curated typed contexts rather than raw data
|
||||
packages or complete fact bundles, and it must not reimplement weather
|
||||
selection or generated-text validation. Context construction rejects
|
||||
report-identity disagreements before template execution.
|
||||
report-identity disagreements before template execution. Every generated-prose
|
||||
insertion uses the `plainText` helper. It retains ordinary prose and paragraph
|
||||
breaks but renders Markdown/HTML syntax, code indentation, and control
|
||||
characters as safe text, so the repository templates remain the sole owners of
|
||||
report structure.
|
||||
|
||||
## Boundaries and verification
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ from rendering.
|
||||
calculations, source selection, or prompt-input shaping to a template.
|
||||
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
|
||||
in generated prose merely to compensate for a template change.
|
||||
- Render every `.GeneratedText` value through `plainText`. It preserves prose
|
||||
and paragraph breaks while escaping Markdown and HTML syntax, removing code
|
||||
indentation, and replacing control characters. Never interpolate generated
|
||||
prose directly: repository templates alone own headings, lists, links, and
|
||||
other Markdown structure.
|
||||
- When changing the generated-prose contract, update the matching prompt,
|
||||
schema, validator, render context, and template together. The validation and
|
||||
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
|
||||
@@ -69,7 +74,7 @@ Minimal list pattern:
|
||||
|
||||
```gotemplate
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
@@ -82,6 +87,7 @@ Templates have these helpers in addition to Go template built-ins:
|
||||
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
|
||||
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
|
||||
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
|
||||
| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
|
||||
|
||||
For example, the alert partial uses the first two functions to decide whether
|
||||
to render the section:
|
||||
|
||||
@@ -627,6 +627,36 @@ func TestBuildDailyRenderContext(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidatedGeneratedTextRendersAsPlainText(t *testing.T) {
|
||||
generated, _, err := ValidateDaily([]byte("{\"summary\":\"Summary.\\n## Fabricated Alert\\n- Fabricated warning\",\"forecast_discussion\":[\"Discussion with [unsafe](javascript:alert(1))\"],\"precipitation_timing\":\"Timing.\\n```not code```\"}"))
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateDaily() error = %v", err)
|
||||
}
|
||||
context, err := BuildDailyRenderContext(testDailyMetadata(), testDailySnapshot(t), generated, testDailyDerived())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDailyRenderContext() error = %v", err)
|
||||
}
|
||||
rendered, err := reporttemplate.Render("daily", context)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"Summary.\n\\## Fabricated Alert\n\\- Fabricated warning",
|
||||
"Discussion with \\[unsafe\\]\\(javascript:alert\\(1\\)\\)",
|
||||
"Timing.\n\\`\\`\\`not code\\`\\`\\`",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered report missing escaped generated prose %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"\n## Fabricated Alert", "\n- Fabricated warning", "[unsafe](javascript:alert(1))", "```not code```"} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("rendered report preserved generated Markdown structure %q:\n%s", unwanted, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyRenderContextAllowsOmittedOptionalModules(t *testing.T) {
|
||||
snapshot, err := module.NewSnapshot(nil)
|
||||
if err != nil {
|
||||
|
||||
89
internal/reporttemplate/plain_text.go
Normal file
89
internal/reporttemplate/plain_text.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package reporttemplate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var inlineMarkdownReplacer = strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
"`", "\\`",
|
||||
"*", "\\*",
|
||||
"_", "\\_",
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"<", "\\<",
|
||||
">", "\\>",
|
||||
"!", "\\!",
|
||||
"~", "\\~",
|
||||
"|", "\\|",
|
||||
"&", "\\&",
|
||||
)
|
||||
|
||||
// plainText preserves prose and paragraph breaks while preventing dynamic
|
||||
// values from creating Markdown structure owned by repository templates.
|
||||
func plainText(value string) string {
|
||||
value = strings.ReplaceAll(value, "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
lines := strings.Split(value, "\n")
|
||||
for index, line := range lines {
|
||||
lines[index] = escapeMarkdownLine(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func escapeMarkdownLine(line string) string {
|
||||
indentEnd := 0
|
||||
spaces := 0
|
||||
hasTab := false
|
||||
for indentEnd < len(line) {
|
||||
switch line[indentEnd] {
|
||||
case ' ':
|
||||
spaces++
|
||||
indentEnd++
|
||||
case '\t':
|
||||
hasTab = true
|
||||
indentEnd++
|
||||
default:
|
||||
goto escaped
|
||||
}
|
||||
}
|
||||
|
||||
escaped:
|
||||
indent := line[:indentEnd]
|
||||
if hasTab || spaces >= 4 {
|
||||
indent = ""
|
||||
}
|
||||
return indent + escapeMarkdownBlockPrefix(escapeMarkdownInline(line[indentEnd:]))
|
||||
}
|
||||
|
||||
func escapeMarkdownInline(value string) string {
|
||||
value = inlineMarkdownReplacer.Replace(value)
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsControl(r) {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func escapeMarkdownBlockPrefix(value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
switch value[0] {
|
||||
case '#', '+', '-', '>', '=':
|
||||
return "\\" + value
|
||||
}
|
||||
|
||||
index := 0
|
||||
for index < len(value) && value[index] >= '0' && value[index] <= '9' {
|
||||
index++
|
||||
}
|
||||
if index > 0 && index < len(value) && value[index] == '.' {
|
||||
return value[:index] + "\\" + value[index:]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -920,6 +920,94 @@ func TestRenderHourlyUsesSharedOpenEndedPrecipitationTiming(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTreatsGeneratedProseAsPlainText(t *testing.T) {
|
||||
const summary = "Summary paragraph.\n\n## Fabricated Alert\n- Fabricated warning\n1. Fabricated list\n> Fabricated quote"
|
||||
const discussion = "Discussion paragraph.\n\n<script>unsafe</script>\n[unsafe](javascript:alert(1))\n```code```\n**emphasis**\n indented code\x00"
|
||||
const timing = "Timing paragraph.\n\n## Fabricated Timing"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
context any
|
||||
}{
|
||||
{
|
||||
name: "daily",
|
||||
template: "daily",
|
||||
context: testDailyRenderContext{
|
||||
Report: testDailyReportContext{Title: "Daily"},
|
||||
GeneratedText: testDailyGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
|
||||
Modules: testDailyModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "today",
|
||||
template: "today",
|
||||
context: testTodayRenderContext{
|
||||
Report: testTodayReportContext{Title: "Today"},
|
||||
GeneratedText: testTomorrowGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
|
||||
Modules: testTodayModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tomorrow",
|
||||
template: "tomorrow",
|
||||
context: testTomorrowRenderContext{
|
||||
Report: testTomorrowReportContext{Title: "Tomorrow"},
|
||||
GeneratedText: testTomorrowGeneratedText{Summary: summary, ForecastDiscussion: []string{discussion}, PrecipitationTiming: timing},
|
||||
Modules: testTomorrowModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hourly",
|
||||
template: "hourly",
|
||||
context: testRenderContext{
|
||||
Report: testReportContext{Title: "Hourly"},
|
||||
GeneratedText: testGeneratedText{Summary: summary, ForecastDiscussion: discussion, PrecipitationTiming: timing},
|
||||
Modules: testModules{PrecipTiming: testPrecipitationTimingForGeneratedProseTest()},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rendered, err := Render(test.template, test.context)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
text := string(rendered)
|
||||
for _, want := range []string{
|
||||
"Summary paragraph.\n\n\\## Fabricated Alert\n\\- Fabricated warning\n1\\. Fabricated list\n\\> Fabricated quote",
|
||||
"Discussion paragraph.\n\n\\<script\\>unsafe\\</script\\>\n\\[unsafe\\]\\(javascript:alert\\(1\\)\\)\n\\`\\`\\`code\\`\\`\\`\n\\*\\*emphasis\\*\\*\nindented code ",
|
||||
"Timing paragraph.\n\n\\## Fabricated Timing",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("rendered template missing escaped prose %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"\n## Fabricated Alert",
|
||||
"\n- Fabricated warning",
|
||||
"\n1. Fabricated list",
|
||||
"\n> Fabricated quote",
|
||||
"<script>unsafe</script>",
|
||||
"[unsafe](javascript:alert(1))",
|
||||
"```code```",
|
||||
"**emphasis**",
|
||||
"\n indented code",
|
||||
"\n## Fabricated Timing",
|
||||
} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("rendered template preserved Markdown structure %q:\n%s", unwanted, text)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testPrecipitationTimingForGeneratedProseTest() *testPrecipTiming {
|
||||
return &testPrecipTiming{PrecipitationWindows: []testPrecipWindow{{}}}
|
||||
}
|
||||
|
||||
func TestUnknownAssetsReturnActionableErrors(t *testing.T) {
|
||||
if _, err := Template("missing"); err == nil || !strings.Contains(err.Error(), `unknown report template "missing"`) {
|
||||
t.Fatalf("Template() error = %v, want unknown template", err)
|
||||
|
||||
@@ -11,6 +11,7 @@ func templateFuncs() template.FuncMap {
|
||||
"hasRelevantAlerts": hasRelevantAlerts,
|
||||
"hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk,
|
||||
"isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk,
|
||||
"plainText": plainText,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
{{ plainText .GeneratedText.Summary }}
|
||||
|
||||
{{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
|
||||
## Forecast Discussion
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ end }}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
{{ plainText .GeneratedText.Summary }}
|
||||
|
||||
{{ template "alert_digest" . }}## Current Conditions
|
||||
|
||||
@@ -18,4 +18,4 @@
|
||||
{{ end -}}
|
||||
## Forecast Discussion
|
||||
|
||||
{{ .GeneratedText.ForecastDiscussion }}
|
||||
{{ plainText .GeneratedText.ForecastDiscussion }}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
{{ range . }}{{ $window := . }}
|
||||
- **{{ if or .PeriodEndsHourLabel .PeriodEnds }}{{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ else }}Starting at {{ if .PeriodBeginsHourLabel }}{{ .PeriodBeginsHourLabel }}{{ else }}{{ .PeriodBegins }}{{ end }}{{ end }}**{{ with .PeriodEndsHourLabel }} to **{{ . }}**{{ else }}{{ with .PeriodEnds }} to **{{ . }}**{{ end }}{{ end }}: {{ with .ExpectationPhrase }}{{ . }}{{ else }}Chance of precipitation.{{ end }}{{ with .MaxPopPercent }} The peak precipitation chance is {{ . }}%{{ with $window.MaxPopHourLabel }} at {{ . }}{{ else }}{{ with $window.MaxPopTime }} at {{ . }}{{ end }}{{ end }}.{{ end }}
|
||||
{{ end }}{{ with $.GeneratedText.PrecipitationTiming }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ end }}
|
||||
{{ end }}{{ end }}{{ end }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
{{ plainText .GeneratedText.Summary }}
|
||||
|
||||
{{ template "alert_digest" . }}{{ with .Modules.CurrentConditions }}
|
||||
## Current Conditions
|
||||
@@ -13,5 +13,5 @@
|
||||
{{ template "today_daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
|
||||
## Forecast Discussion
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ end }}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
**Forecast date:** {{ .Report.ForecastDateLabel }}
|
||||
**Updated:** {{ .Report.GeneratedAtLabel }}
|
||||
|
||||
{{ .GeneratedText.Summary }}
|
||||
{{ plainText .GeneratedText.Summary }}
|
||||
|
||||
{{ template "alert_digest" . }}{{ template "daypart_forecast" . }}{{ if and .Modules.PrecipTiming .Modules.PrecipTiming.PrecipitationWindows }}{{ template "precipitation_timing" . }}{{ else }}{{ end -}}
|
||||
## Forecast Discussion
|
||||
{{ range .GeneratedText.ForecastDiscussion }}
|
||||
{{ . }}
|
||||
{{ plainText . }}
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user