From 940df954185eb526736db7d72557560cc680fe16 Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 14:44:58 +0200 Subject: [PATCH] =?UTF-8?q?fix(submissions):=20t-paliad-259=20=E2=80=94=20?= =?UTF-8?q?universal=20=5Fskeleton.docx=20for=20fallback=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: m noticed the submission generator's preview still shows the raw HL Patents Style .dotm letterhead for every submission_code that has no per-firm template. Confirmed live: paliad.de's /healthz is green, the preview path and /generate path both flow through resolveSubmissionTemplate, and the only code wired in submissionTemplateRegistry is de.inf.lg.erwidg (t-paliad-241). For every other code, the fallback was the bare letterhead with zero placeholders — exactly what m observed. Fix: slot a universal _skeleton.docx between the per-firm code-specific template and the macro-only HL Patents Style: per-firm/{code}.docx → _skeleton.docx → HL Patents Style.dotm The skeleton carries every placeholder SubmissionVarsService resolves (all 48 keys across firm.*, today.*, user.*, project.*, parties.*, rule.*, deadline.*) without baking in submission_code-specific prose, so any code lands with variables substituted instead of the bare letterhead. Changes: - scripts/gen-skeleton-submission-template/main.go: byte-reproducible .docx generator mirroring gen-demo-submission-template but with a code-agnostic body (no Klageerwiderung "I./II./III." structure, a single [Schriftsatztext] block the lawyer replaces). One run per placeholder so the renderer's pass-1 substitution catches every token. - internal/handlers/files.go: register slug submission/_skeleton.docx + fetchSubmissionSkeletonBytes helper (same stale-while-revalidate semantics as the existing per-code and HL-Patents-Style fetchers). - internal/handlers/submission_drafts.go: insert the skeleton lookup between fetchSubmissionTemplateBytes (per-firm code) and fetchHLPatentsStyleBytes (bare letterhead). HL Patents Style remains the final fallback for resilience if mWorkRepo is unreachable. The companion _skeleton.docx is committed to m/mWorkRepo at 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx (commit f2659e4) so the file proxy can fetch it on first request. Build hygiene: go build ./... clean, go test ./internal/... clean, bun run build clean. --- internal/handlers/files.go | 60 ++++ internal/handlers/submission_drafts.go | 25 +- .../gen-skeleton-submission-template/main.go | 303 ++++++++++++++++++ 3 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 scripts/gen-skeleton-submission-template/main.go diff --git a/internal/handlers/files.go b/internal/handlers/files.go index 34e2463..4682091 100644 --- a/internal/handlers/files.go +++ b/internal/handlers/files.go @@ -65,8 +65,28 @@ var fileRegistry = map[string]fileEntry{ RepoName: "mWorkRepo", FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx", }, + // Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter + // that carries every placeholder SubmissionVarsService resolves but no + // submission_code-specific body structure. Slot between the per-firm + // per-code template and the bare HL Patents Style .dotm fallback: every + // submission_code without a dedicated template still renders with + // variables substituted instead of the macro-only letterhead. + skeletonSubmissionSlug: { + RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx", + DownloadName: branding.Name + " — Schriftsatz-Skelett.docx", + ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + RepoOwner: "m", + RepoName: "mWorkRepo", + FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx", + }, } +// skeletonSubmissionSlug names the universal skeleton template inside +// the shared fileRegistry cache. Exported via a const so handler code +// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to +// the same string the registry uses. +const skeletonSubmissionSlug = "submission/_skeleton.docx" + // submissionTemplateRegistry maps a deadline-rule submission_code to a // fileRegistry slug. Lookup order matches the cronus design fallback // chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then @@ -189,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"}) } +// fetchSubmissionSkeletonBytes returns the cached universal skeleton +// template bytes plus its provenance SHA. Sits between the per-firm +// per-submission_code template (fetchSubmissionTemplateBytes) and the +// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in +// resolveSubmissionTemplate's fallback chain — used for every +// submission_code that has no dedicated template registered. Same +// stale-while-revalidate semantics as the rest of the file proxy: first +// call warms the cache synchronously from mWorkRepo via Gitea; later +// calls return immediately while a background refresh runs. +func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) { + entry, ok := fileRegistry[skeletonSubmissionSlug] + if !ok { + return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug) + } + ce := getCacheEntry(skeletonSubmissionSlug) + + ce.mu.RLock() + hasData := len(ce.data) > 0 + needsCheck := time.Since(ce.lastChecked) >= checkInterval + ce.mu.RUnlock() + + if !hasData { + if err := fileFetch(ce, entry); err != nil { + return nil, "", err + } + } else if needsCheck { + go fileCheckAndRefresh(ce, entry) + } + + ce.mu.RLock() + defer ce.mu.RUnlock() + if len(ce.data) == 0 { + return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug) + } + out := make([]byte, len(ce.data)) + copy(out, ce.data) + _ = ctx + return out, ce.sha, nil +} + // fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm // bytes. Shared accessor used by both the /files/{slug} download path // (Word auto-update channel) and the submission generator diff --git a/internal/handlers/submission_drafts.go b/internal/handlers/submission_drafts.go index 6ae8841..66c8f20 100644 --- a/internal/handlers/submission_drafts.go +++ b/internal/handlers/submission_drafts.go @@ -904,16 +904,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, // resolveSubmissionTemplate returns the .docx bytes for the given // submission code. Lookup order matches the cronus design fallback chain -// §8: per-firm template registered in submissionTemplateRegistry first, -// then the universal HL Patents Style as the global fallback. The -// returned SHA is the cache entry's commit SHA so the export audit row -// can record provenance. +// §8 plus the t-paliad-259 universal-skeleton slot: +// +// 1. per-firm per-submission_code template registered in +// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code- +// specific structure plus the full variable bag. +// 2. universal _skeleton.docx — same variable bag, no submission_code- +// specific prose. Catches every code without a dedicated template +// so the editor preview / generate flow still has variables to +// substitute instead of falling through to the bare letterhead. +// 3. universal HL Patents Style .dotm — macro-only letterhead, no +// placeholders. Final fallback when even the skeleton is unreachable +// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour +// for resilience. +// +// The returned SHA is the cache entry's commit SHA so the export audit +// row can record provenance. func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) { if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil { return nil, "", err } else if found { return data, sha, nil } + if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil { + return data, sha, nil + } else { + log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err) + } bytes, err := fetchHLPatentsStyleBytes(ctx) if err != nil { return nil, "", err diff --git a/scripts/gen-skeleton-submission-template/main.go b/scripts/gen-skeleton-submission-template/main.go new file mode 100644 index 0000000..175a9eb --- /dev/null +++ b/scripts/gen-skeleton-submission-template/main.go @@ -0,0 +1,303 @@ +// Universal-skeleton submission template generator (t-paliad-259). +// +// One-shot authoring tool that emits a minimal but Word-compatible +// .docx file exercising every placeholder SubmissionVarsService +// resolves — without baking in any submission_code-specific prose. +// +// Drop the output into m/mWorkRepo at +// +// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx +// +// so paliad's submission generator picks it up via the fallback chain +// slotted between the per-submission_code template and the bare +// universal HL Patents Style .dotm. Any submission_code that has no +// per-firm template still gets a draft populated with variables +// instead of the macro-only letterhead. +// +// Why a separate file from de.inf.lg.erwidg.docx: that one is a +// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt / +// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA +// appeal, that body structure is wrong. The universal skeleton drops +// the structure and leaves a single neutral body block the lawyer +// replaces — every variable still resolves regardless of code. +// +// Run: +// +// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx +// +// Output is byte-reproducible (zip mtimes pinned to a fixed UTC +// timestamp). +package main + +import ( + "archive/zip" + "bytes" + "flag" + "fmt" + "os" + "strings" + "time" +) + +func main() { + out := flag.String("out", "_skeleton.docx", "output .docx path") + flag.Parse() + + docx, err := buildDocx() + if err != nil { + fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err) + os.Exit(1) + } + if err := os.WriteFile(*out, docx, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err) + os.Exit(1) + } + fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx)) +} + +var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC) + +func buildDocx() ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + add := func(name, body string) error { + hdr := &zip.FileHeader{ + Name: name, + Method: zip.Deflate, + Modified: fixedTime, + } + w, err := zw.CreateHeader(hdr) + if err != nil { + return fmt.Errorf("create %s: %w", name, err) + } + if _, err := w.Write([]byte(body)); err != nil { + return fmt.Errorf("write %s: %w", name, err) + } + return nil + } + + if err := add("[Content_Types].xml", contentTypesXML); err != nil { + return nil, err + } + if err := add("_rels/.rels", rootRelsXML); err != nil { + return nil, err + } + if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil { + return nil, err + } + if err := add("word/styles.xml", stylesXML); err != nil { + return nil, err + } + if err := add("word/document.xml", buildDocumentXML()); err != nil { + return nil, err + } + + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("finalise zip: %w", err) + } + return buf.Bytes(), nil +} + +const contentTypesXML = ` + + + + + +` + +const rootRelsXML = ` + + +` + +const documentRelsXML = ` + + +` + +const stylesXML = ` + + + + + + + + + + + + + + + + +` + +// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead + +// case caption + parties + submission heading + deadline + a single +// neutral body block. Mirrors the variable bag from SubmissionVarsService +// (48 keys across firm.* / today.* / user.* / project.* / parties.* / +// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific +// structure. A lawyer customising this template for a UPC SoC, EPO +// opposition, or DPMA appeal replaces the [Schriftsatztext] block and +// renames the party labels — every placeholder still resolves regardless +// of the submission_code chosen. +// +// Every placeholder occupies its own run so the renderer's pass-1 +// (format-preserving, single-run) substitution catches it. The +// DEMO/SKELETON banner makes it obvious this is a starter template and +// not approved firm content. +func buildDocumentXML() string { + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + + skeletonBanner(&b) + + heading1(&b, "{{firm.name}}") + plain(&b, "Bearbeiter: {{user.display_name}}") + plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}") + plain(&b, "Datum: {{today.long_de}} ({{today.iso}})") + plainOptional(&b, "{{firm.signature_block}}") + + heading1(&b, "{{project.court}}") + plain(&b, "Aktenzeichen: {{project.case_number}}") + plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})") + plain(&b, "Instanz: {{project.instance_level}}") + + heading2(&b, "In der Sache") + plain(&b, "{{parties.claimant.name}}") + plain(&b, "vertreten durch {{parties.claimant.representative}}") + bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —") + plain(&b, "") + plain(&b, "gegen") + plain(&b, "") + plain(&b, "{{parties.defendant.name}}") + plain(&b, "vertreten durch {{parties.defendant.representative}}") + bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —") + plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}") + + heading2(&b, "Betreff") + plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})") + plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}") + plain(&b, "Projekttitel: {{project.title}}") + plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})") + plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}") + plain(&b, "Internes Aktenzeichen: {{project.reference}}") + + heading1(&b, "{{rule.name}}") + plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})") + plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})") + plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}") + + heading2(&b, "Frist") + plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}") + plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})") + plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}") + plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}") + + heading2(&b, "Schriftsatztext") + plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]") + plain(&b, "") + plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]") + + heading2(&b, "Schlussformel") + plain(&b, "{{today.long_de}}") + plain(&b, "") + plain(&b, "{{user.display_name}}") + plain(&b, "{{firm.name}}") + + // Locale-aware verification block — exercises every EN/DE alias the + // variable bag carries (today.long_en, deadline.due_date_long_en, + // project.our_side_en, project.proceeding.name_en, rule.name_en) and + // the bare {{today}} alias. A lawyer customising the template can + // delete this block; the renderer round-trips it cleanly today. + heading2(&b, "Locale-aware variants (SKELETON)") + plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}") + plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}") + plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}") + plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}") + plain(&b, "Today (bare alias): {{today}}") + + b.WriteString(``) + return b.String() +} + +func skeletonBanner(b *strings.Builder) { + b.WriteString(`SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)`) +} + +func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) } + +func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) } + +func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) } + +func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) } + +func bold(b *strings.Builder, text string) { + b.WriteString(``) + b.WriteString(``) + b.WriteString(xmlEscape(text)) + b.WriteString(``) +} + +func paragraph(b *strings.Builder, style, text string, italic bool) { + b.WriteString(``) + if style != "" { + b.WriteString(``) + } + for _, seg := range splitOnPlaceholders(text) { + b.WriteString(``) + if italic { + b.WriteString(``) + } + b.WriteString(``) + b.WriteString(xmlEscape(seg)) + b.WriteString(``) + } + b.WriteString(``) +} + +func splitOnPlaceholders(s string) []string { + if s == "" { + return []string{""} + } + var out []string + for { + open := strings.Index(s, "{{") + if open < 0 { + out = append(out, s) + return out + } + close := strings.Index(s[open:], "}}") + if close < 0 { + out = append(out, s) + return out + } + end := open + close + 2 + if open > 0 { + out = append(out, s[:open]) + } + out = append(out, s[open:end]) + s = s[end:] + if s == "" { + return out + } + } +} + +func xmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "'", "'") + return s +}