Keep generated prose out of Markdown structure

This commit is contained in:
2026-08-13 02:17:11 +00:00
parent 2bd921f247
commit a18d5134c7
12 changed files with 233 additions and 13 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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 {

View 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
}

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ func templateFuncs() template.FuncMap {
"hasRelevantAlerts": hasRelevantAlerts,
"hasEnhancedOrHigherSPCRisk": hasEnhancedOrHigherSPCRisk,
"isEnhancedOrHigherSPCRisk": isEnhancedOrHigherSPCRisk,
"plainText": plainText,
}
}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}