Merge: t-paliad-363 generation-quality diagnosis (picker filter, layout, Rubrum styling+fill)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-06-01 15:50:56 +02:00

View File

@@ -0,0 +1,357 @@
# Generation-quality diagnosis — docforge loop (2026-06-01)
**Task:** t-paliad-363 · researcher (kepler) · READ-ONLY diagnosis + sliced fix-plan
**Trigger:** m exercised the docforge loop live 2026-06-01 ~15:33 (global `/submissions/new`
picker → start draft "Ohne Projekt" → generate) and surfaced 3 real problems.
**Scope:** investigate + plan only. No code/data/template edits this shift.
Three problems, increasing depth:
1. **P1** — the global picker lists court/trigger acts, not just party submissions.
2. **P2** — the grouped picker table looks layout-broken (proceeding name "in the row flow").
3. **P3** — a generated project-less draft is both **unstyled** (no HL Patents Style) and
**unfilled** (every caption/party value `[KEIN WERT: …]`). This is the docforge core.
---
## P1 — picker lists court acts, not just party submissions
### What m saw
"Service of OLG Judgment" (`de.inf.bgh.urteil_olg`, *Zustellung OLG-Urteil*) appears in the
draftable-submission picker. That is a **court/trigger act** (the service of the lower-court
judgment that *starts* the appeal clock), not a party submission anyone drafts.
### Root cause
The catalog query (`loadSubmissionCatalog`, `internal/handlers/submissions.go:177-211`) filters:
```sql
WHERE dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.event_type = 'filing'
AND dr.submission_code IS NOT NULL AND dr.submission_code <> ''
```
The model **already has a correct court-act tier**: 16 rows with `event_type='decision'` and
`primary_party='court'` (e.g. `de.inf.olg.urteil_olg` "OLG-Urteil", `upc.inf.cfi.decision`
"Entscheidung"). Those are correctly excluded by `event_type='filing'`.
But **8 anchor/trigger rows are mislabeled** `event_type='filing'` with `primary_party='both'`.
They are the "Zustellung …" (service of judgment) / "Veröffentlichung der Erteilung"
(publication-of-grant) events that **start** a proceeding's deadline chain. They are used
**structurally as `parent_id` anchors** — e.g. `de.inf.bgh.urteil_olg` is the parent of
Revisionsfrist, Revisionsbegründung, NZB, Mündliche Verhandlung BGH, BGH-Urteil. They are not
draftable by a party.
### Affected rows (the 8 to re-kind)
| submission_code | proceeding | name | event_type (is) | primary_party (is) | anchors (children) |
|---|---|---|---|---|---|
| `de.inf.bgh.urteil_olg` | de.inf.bgh | Zustellung OLG-Urteil | filing | both | Revisionsfrist, Revisionsbegr., NZB, NZB-Begr., Termin, BGH-Urteil |
| `de.inf.olg.urteil_lg` | de.inf.olg | Zustellung LG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, OLG-Urteil |
| `de.null.bgh.urteil_bpatg` | de.null.bgh | Zustellung BPatG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, BGH-Urteil |
| `dpma.appeal.bgh.entsch_bpatg` | dpma.appeal.bgh | Zustellung BPatG-Entscheidung | filing | both | Rechtsbeschwerde, BGH-Entscheidung |
| `dpma.appeal.bpatg.entscheidung` | dpma.appeal.bpatg | Zustellung DPMA-Entscheidung | filing | both | Beschwerde, Termin, BPatG-Entscheidung |
| `epa.opp.boa.entsch` | epa.opp.boa | Zustellung der Beschwerdeentscheidung | filing | both | Beschwerdeeinlegung, Beschwerdebegr. |
| `dpma.opp.dpma.publish` | dpma.opp.dpma | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist, DPMA-Entscheidung |
| `epa.opp.opd.grant` | epa.opp.opd | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist |
(Verified read-only via Supabase MCP against `paliad.deadline_rules_unified`, 2026-06-01.)
### The clean-filter question — and why a pure picker-filter does NOT work
The task hypothesised "exclude `primary_party='court'`". **That does not catch these 8** — they
carry `primary_party='both'`, not `'court'`. And `'both'` cannot be excluded wholesale: many real
party submissions are `'both'` (Berufungsbegründung, Anschlussberufung, every UPC appeal brief).
There is **no existing column value that isolates the 8** from genuine party submissions — the only
thing wrong with them is the mislabel itself. So a stronger picker WHERE-clause cannot cleanly fix
this without first fixing the data.
### → FORK (i): data-fix vs filter — RECOMMENDATION: **data correction**
**Recommended — data correction (re-kind the 8 anchor rows):**
Set on each of the 8 rows: `event_type = 'decision'` and `primary_party = 'court'`, matching the
16 sibling court-act rows the model already has. Then the *existing* picker filter
(`event_type='filing'`) excludes them automatically — **no handler change**.
- **Safe for anchoring:** the deadline chain links via `parent_id` (uuid FK), *not* via
`event_type`/`primary_party`. Re-kinding does not touch the FK, so Revisionsfrist &co. still
anchor correctly.
- **`submission_code` retained:** the 16 sibling `decision` rows keep their submission_codes too
(they're used as stable anchor identifiers), so retaining them here is consistent — no need to
strip.
- **Caveat to confirm before applying:** verify nothing computes a *deadline trigger* off
`event_type='filing'` for these specific rows (a quick grep of the deadline engine for
`event_type` filters). The `parent_id` graph is the structural link, so this is expected-safe,
but the coder should confirm.
**Defensive hardening (do in addition, not instead):** add `AND dr.primary_party IS DISTINCT FROM
'court'` to `loadSubmissionCatalog`'s WHERE clause. After the data fix the 8 rows are `'court'`, so
this is belt-and-braces: any future mis-kinded court act can't leak into the picker even if its
`event_type` is wrong.
**Why not "stronger filter only":** there is no filter on current columns that removes the 8
without also removing legitimate `'both'` party submissions. A filter alone cannot fix mislabeled
data — it would have to encode a brittle name-pattern blocklist ("Zustellung%/Veröffentlichung%"),
which is the kind of tech debt the project bars.
---
## P2 — grouped picker table layout
### What m saw
The full proceeding name ("Revision / Non-admission Appeal BGH (Infringement) de.inf.bgh") appears
"in the row flow" so the columns (Schriftsatz | Partei | Rechtsgrundlage | Entwurf starten) look
broken.
### Root cause — it is NOT structural; it is **visual weight**
The grouped rows are hydrated by `frontend/src/client/submissions-new.ts`. The group header is
**already a correct full-width colspan row** (committed `a911a2d`, 2026-05-23 — i.e. m saw this
exact code):
```ts
// client/submissions-new.ts:167-174
const colspan = 4;
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup">
<span class="entity-table-group-header__name">${esc(group.name)}</span>
<span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
```
CSS for it exists at `frontend/src/styles/global.css:6066-6109`. So structurally the name spans all
4 columns — it is not crammed into column 1.
**The actual defect is contrast.** The group-header band paints
`background: var(--color-bg-subtle)` (`global.css:6074`) = **`#f7f3f0`** — and the table `thead`
column-label row uses the **same** `--color-bg-subtle` (`global.css:7979`), over a near-cream page
background, with faint `--color-border: #e1dcd6` top/bottom rules. Result: the proceeding-name band
is barely distinguishable from the column-header band directly above it and from the page. The eye
reads it as un-delimited floating text sitting between the column labels and the data — "the name is
in the row flow and the columns look broken." The long EN proceeding names amplify it.
(Could not confirm against the live render — `/submissions/new` is auth-gated and redirects to
`/login`. Diagnosis is from source + the CSS variable values, which are conclusive about contrast.)
### → Fix (quick — pure CSS, no fork)
Give `.entity-table-group-header th` a distinctly stronger treatment so each proceeding group reads
as its own band, clearly separated from the column header:
- a heavier/darker background than the `thead` (e.g. a deeper tint, or the midnight/lime accent the
`--own` modifier already uses at `6089-6093`), **and/or**
- a heavier top border / a small accent rule, plus a touch more vertical padding.
Anchor: `frontend/src/styles/global.css:6066-6109`. No TS change needed — the markup is already
correct. The sibling project-scoped tab (`client/submissions.ts:137-141`) shares the same classes,
so the CSS fix improves both at once.
(If the coder finds the live deploy *doesn't* show the colspan header at all, the secondary
hypothesis is a stale `/assets/submissions-new.js` bundle — verify the deployed bundle includes the
`a911a2d` grouping code. Source on `main` is correct as of this diagnosis.)
---
## P3 — generated Rubrum is UNSTYLED + UNFILLED (the docforge core)
m generated a draft from the global picker ("Ohne Projekt") and got: letterhead filled
(`Schriftsatz von HLC / Matthias Siebels, duesseldorf`) but **every** caption/party/project value =
`[KEIN WERT: …]` and the Rubrum is **plain text with no HL Patents Style formatting**. Two distinct
layers, diagnosed separately.
### P3(a) — UNSTYLED — confirmed: it's `BuildFallbackSkeleton` output
**The path m hit.** `resolveSubmissionTemplate` (`internal/handlers/submission_drafts.go:1359-1408`)
is a 7-tier fallback chain. For any code without a dedicated per-code template (only
`de.inf.lg.erwidg` has one), and with the firm/universal skeletons rejected by the merge-safe guard
(see below), generation lands on **tier 6 — `docx.BuildFallbackSkeleton(lang)`**
(`pkg/docforge/docx/fallback_skeleton.go`).
**What that emits.** A minimal in-process .docx whose `styles.xml`
(`fallback_skeleton.go:121-138`) defines only three generic styles:
`Heading1` (bold 14pt), `Heading2` (bold 12pt), `Normal` (nothing). The Rubrum is built from those
(`buildFallbackDocumentXML`, lines 215-269) — `fbHeading2` / `fbPlain` / `fbBold`. **No HL Patents
Style typography, no named Rubrum styles, no firm font/numbering.** m's "plain text" is exactly
this. Confirmed.
**Why the styled base isn't used (the merge-safe guard).** Tiers 3/4/5 fetch the firm/universal
skeletons from mWorkRepo but are **guarded by `docx.HasMergePlaceholders`** (lines 1378/1384/1388).
I fetched the firm-skeleton
(`HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`, 157 KB) and unzipped it:
- **`styles.xml` carries the FULL HL Patents Style catalogue** — including the exact named styles a
Rubrum needs:
- `HLpat-Table-Recitals-Party`, `HLpat-Table-Recitals-PartyDetails`,
`HLpat-Table-Recitals-PartyRoles`, `HLpat-Table-Recitals-Sequencers` — the party-block (Rubrum)
styles (e.g. `Recitals-Party` = `basedOn Body1`, bold, numbered list `numId 4`).
- `HLpat-Heading-H1..H5`, `HLpat-Body-B0/B1`, `HLpat-Header-Section/Subsection`,
`HLpat-Signature`, `SignatureBlock`, `HLpat-Requests-Intro`, `HLpat-Citation`, …
- full letterhead: `header1/header2` + `footer1/footer2` + `media/image1.jpeg` (logo) +
`media/image2.jpg`, theme1.xml, fontTable.xml.
- **But its `document.xml` body is ANCHORS-ONLY** — ten `{{#section:KEY}}…{{/section:KEY}}` pairs
(`letterhead`, `caption`, `introduction`, `requests`, `facts`, `legal_argument`, `evidence`,
`exhibits`, `closing`, `signature`) and **zero `{{key}}` merge placeholders**.
So `HasMergePlaceholders` correctly rejects it for the **merge** path (feeding it to `merge.go`
would print literal `{{#section:…}}` junk — the t-paliad-358 A-S1 regression). The styled base is a
**Composer base**, consumed by `pkg/docforge/docx/compose.go` (exists, 22 KB), which splices section
content into those anchors. **The merge path and the Composer path are different engines, and
one-click /generate + project-less export run the merge path.**
**The styled HL base therefore already carries every named paragraph style the Rubrum needs.** The
gap is purely that generation runs the merge path (→ bare in-process skeleton) rather than a styled
base.
#### → FORK (ii): styling approach — three framed options
- **Option A — drive generation off the Composer path (the "real docforge").**
Route one-click /generate and the project-less draft-export through `compose.go` with the
firm-skeleton base + the section seeds (`submission_bases.section_spec`, migs 146/150; the caption
seed already exists per t-paliad-358). Output is fully HL-styled (Rubrum in `HLpat-Table-Recitals-*`,
headings in `HLpat-Heading-*`, signature in `HLpat-Signature`, logo letterhead) **and** carries
seeded section bodies. *Cost:* the biggest change — generation currently never calls the Composer
for these entry points; needs routing + the project-less bag wired into the Composer.
*This is the long-term correct path.*
- **Option B — author a merge-safe styled firm-skeleton (tracer-bullet, RECOMMENDED first).**
Start from the firm-skeleton's full part-set (its `styles.xml`, theme, fonts, headers/footers,
logo media) and replace **only** its anchors-only `document.xml` body with a **merge-safe Rubrum
body** that uses the HLpat style IDs (`HLpat-Table-Recitals-Party` for party lines,
`HLpat-Heading-H2` for section heads, `HLpat-Signature` for the signature block) and the same
`{{key}}` / `{{caption.*}}` placeholders the current fallback already uses. Publish it as a
merge-safe `_firm-skeleton.docx` (or a new slug). The resolver's **tier 4 already prefers a
merge-safe firm-skeleton automatically** — its own comment says: *"should a merge-safe
firm-skeleton (with letterhead) be restored later it is preferred again automatically"*
(`submission_drafts.go:1352-1354`). So this needs **no handler change** — drop in the file and the
guard picks it. Output: HL-styled Rubrum + letterhead via the existing merge path.
*Cost:* author one .docx body (the styles/letterhead already exist in the base); smallest blast
radius; reuses the entire existing merge pipeline and the `caption.*` keys.
- **Option C — graft real styles into `BuildFallbackSkeleton`.**
Embed the firm `styles.xml` + theme + letterhead into the in-process builder and map its
paragraphs to HLpat styles. Keeps a code-only last-resort that is also styled. *Cost:* duplicates
the base's 89 KB styles.xml into Go and must track it on every firm-template change — more
maintenance than B for the same visual result. Only worth it if we want even the never-reached
last-ditch tier styled.
**Recommendation:** **B first** (tracer bullet — slots into the existing tier-4 hook, styled Rubrum
this iteration), **A as the strategic target** (full Composer-driven generation with seeded
sections). C is not recommended.
### P3(b) — UNFILLED (`[KEIN WERT]`) — confirmed cause + a pinned resolver-wiring gap
**Confirmed cause: no project context.** The draft was started "Ohne Projekt" → `project_id` NULL.
In `SubmissionVarsService.Build` (`internal/services/submission_vars.go:125-216`), the project-less
branch is **lines 171-178**:
```go
if in.ProjectID == nil {
// Project-less draft (t-paliad-243): no project / parties / deadline state to resolve.
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
return out, nil
}
```
`resolvers` at that point is **only** `firmResolver, todayResolver, userResolver,
proceduralEventResolver` (lines 158-163). It does **not** include `projectResolver`,
**`captionResolver`**, `partiesResolver`, or `deadlineResolver` (those are appended only on the
project branch, lines 203-208). So `caption.*`, `project.*`, `parties.*` are never populated →
every one renders `[KEIN WERT: …]`. firm/user/today DID fill (letterhead correct) — consistent with
m's screenshot.
**The resolver-wiring gap (pinned).** This is the important finding the task asked for. Even with
no project, the draft has a `submission_code`, and:
- the `rule` is **already loaded** on this path (`loadPublishedRule`, line 138 — *before* the
`ProjectID == nil` check), and
- `DeadlineRule.ProceedingTypeID *int` is populated (`internal/models/…:114`; `proceeding_type_id`
is in `ruleColumns`, `deadline_rule_service.go:42`), and
- `resolveCaption(p, pt)` is a **pure function that tolerates a nil project**
(`submission_vars.go:453-533`): it guards `if pt != nil` (464) and `if p != nil` (506). With `pt`
set and `p == nil` it resolves **heading / subject / claimant_designation / defendant_designation
/ versus** from the proceeding alone (jurisdiction + dotted-code "nature" segment + the mig-137
role-label overrides). Only the *appeal/cassation* designation upgrade needs
`project.instance_level`, which simply falls back to the civil default when absent.
**So `caption.heading_de` is empty on the project-less path purely because the branch never loads
the ProceedingType from `rule.ProceedingTypeID` and never runs `captionResolver` — not because the
data is missing.** The submission_code *does* tie the draft to a proceeding; the wiring just doesn't
use it. This is a genuine gap, independently fixable from the product decision below.
Minimal fix shape (for the coder, if m chooses to fill-what-we-can): in the `ProjectID == nil`
branch, `pt, _ := s.loadProceedingType(ctx, rule.ProceedingTypeID)` (the helper already tolerates a
nil id, lines 275-291) and append `captionResolver{project: nil, pt: pt, lang}` (+ optionally a
proceeding-only `projectResolver` for `project.proceeding.name`). That fills the caption wording and
the proceeding line; party names / case number / court stay blank.
#### → FORK (iii): require-Akte vs fill-what-we-can
- **Option 1 — require an Akte/project to draft.** A draftable submission must be bound to a
project (drop or disable "Ohne Projekt", or block generation until linked). Rubrum is *fully*
filled (project + parties + caption all present). Simplest correctness, but removes the
quick project-less drafting affordance t-paliad-243 deliberately added.
- **Option 2 — fill-what-we-can on a project-less draft (RECOMMENDED, pairs with the wiring fix).**
Keep "Ohne Projekt" but resolve `caption.*` (heading / designations / versus / subject) and the
proceeding line from the submission_code's proceeding, leaving only party names / case number /
court blank for the lawyer. The Rubrum then renders with correct procedural wording instead of
five `[KEIN WERT]`s; the genuinely project-specific blanks remain honest gaps. This is the
resolver-wiring fix above; it does not require the require-Akte product change.
**Recommendation:** **Option 2** — it's the smaller change, preserves the t-paliad-243 affordance,
and turns a wall of `[KEIN WERT]` into a real (if partial) Rubrum. m may *additionally* prefer
"start from Akte" as the *primary* CTA for a fully-filled document, but that's a UX nudge, not a
hard gate.
---
## Sliced fix-plan (tracer-bullet first)
Ordered cheap → meaty. Each slice is independently shippable.
1. **P1 data-fix (quick, data-only).** Re-kind the 8 anchor rows
(`event_type='decision'`, `primary_party='court'`) in `paliad.deadline_rules_unified`. No code.
Confirm the deadline engine doesn't trigger off `event_type='filing'` for them first (expected
safe — anchoring is `parent_id`). *Optionally* add the `primary_party IS DISTINCT FROM 'court'`
guard to `loadSubmissionCatalog` as belt-and-braces.
2. **P2 layout (quick, CSS-only).** Strengthen `.entity-table-group-header th`
(`global.css:6066-6077`) so each proceeding band is visually distinct from the column header.
Verify the deployed `submissions-new.js` bundle is current.
3. **P3(b) fill-what-we-can (small, Go).** Wire `captionResolver` (and a proceeding-only
`projectResolver`) into the project-less branch of `SubmissionVarsService.Build` using
`rule.ProceedingTypeID`. Turns the Rubrum wording from `[KEIN WERT]` into resolved procedural
labels; party/case/court stay blank. (Decide FORK iii first — this *is* Option 2.)
4. **P3(a) styling — tracer bullet (Option B, medium).** Author a merge-safe styled firm-skeleton
(firm-skeleton part-set + a `{{key}}`/`{{caption.*}}` Rubrum body in `HLpat-Table-Recitals-*` /
`HLpat-Heading-*` / `HLpat-Signature`). Drops into the existing tier-4 hook — no handler change.
Combined with slice 3, the project-less draft now renders an HL-styled, partially-filled Rubrum.
5. **P3(a) styling — strategic (Option A, meaty, later).** Route generation through the Composer
(`compose.go`) off the firm-skeleton base with section seeds, for fully-styled documents with
seeded section bodies. Larger change; do after slices 1-4 validate.
---
## The three forks for m
- **(i) P1 — data-fix vs filter:** → **data correction** (re-kind the 8 anchor rows to
`decision`/`court`; existing filter then excludes them). A picker-filter alone *cannot* isolate
them (`primary_party='both'`, shared with real submissions). Optional `primary_party<>'court'`
guard as hardening.
- **(ii) P3 styling — Composer vs merge-safe styled skeleton vs grafted fallback:** → **Option B**
(merge-safe styled firm-skeleton) as the tracer bullet; **Option A** (Composer-driven) as the
strategic target. The styled base already carries all HL Patents Style + Rubrum styles — the work
is a styled body, not new styles.
- **(iii) P3 fill — require-Akte vs fill-what-we-can:** → **Option 2** (fill caption.* from the
submission_code's proceeding on project-less drafts; leave party/case/court blank). Pairs with the
pinned resolver-wiring fix.
---
## Appendix — evidence index
- Picker filter: `internal/handlers/submissions.go:177-211`.
- P1 affected rows + the 16-row correct `decision`/`court` tier + `parent_id` anchoring:
Supabase MCP read-only on `paliad.deadline_rules_unified` (2026-06-01).
- P2 group header: `frontend/src/client/submissions-new.ts:167-174` (commit `a911a2d`, 2026-05-23);
CSS `frontend/src/styles/global.css:6066-6109`; `--color-bg-subtle:#f7f3f0` (`:28`), same as
`thead` (`:7979`); auth-gated render not directly observed.
- P3(a) unstyled fallback: `pkg/docforge/docx/fallback_skeleton.go:53-89,121-138,215-269`;
resolver chain + merge-safe guard `internal/handlers/submission_drafts.go:1359-1408` (esp.
1352-1354, 1378/1384/1388/1395).
- P3(a) styled base: `HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`
(fetched + unzipped: full HLpat styles incl. `HLpat-Table-Recitals-*`; anchors-only body);
Composer `pkg/docforge/docx/compose.go`; registry `internal/handlers/files.go:94-101`.
- P3(b) project-less gap: `internal/services/submission_vars.go:125-216` (branch 171-178),
`resolveCaption` 453-533, `loadProceedingType` 275-291, `rule` load 138; `DeadlineRule.
ProceedingTypeID` (`internal/models/…:114`, `ruleColumns` `deadline_rule_service.go:42`).