Skip to content

AI: Powered Document — all four modes, with a real rendered preview - #177

Merged
Adron merged 2 commits into
mainfrom
issue-15-powered-document
Sep 24, 2026
Merged

Adron merged 2 commits into
mainfrom
issue-15-powered-document

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Stack

Last of five stacked PRs on the AI epic. Based on #174, not main:

PR Branch Base
#137 (open) issue-9-ai-service main
#173 issue-10-ai-gating issue-9-ai-service
#174 issue-14-powered-templates issue-10-ai-gating
this issue-15-powered-document issue-14-powered-templates

Merge in that order.

Hosting: one line, zero code-behind

Views/DocumentsView.xaml is contended by #139, so this uses the same #55/#56 pattern as #174. The entire diff to the contended file is:

<local:PoweredDocumentPanel OpenDocumentCommand="{Binding SelectDocumentCommand}" RefreshCommand="{Binding LoadCommand}" Margin="0,0,0,12"/>

dropped as the first child of the editor column's Grid.Row="1" StackPanel. It collapses itself when #10's IsAiAvailable gate is closed, and it fetches the user's own lists/documents for its source pickers rather than depending on the host's load order or on collections another PR might rename. If that line conflicts, it can go in any StackPanel whose DataContext is DocumentsViewModel.

A reference, not source text — and the errors land on the field

All four modes go through the four-value AiDocumentMode enum. The three derived ones are IDOR-guarded, and all four failure paths were verified live for free (usedToday unchanged across all of them):

probe response cost
from_list + non-owned listId {"error":"List not found.","code":"invalid_input"} 0
from_list + no listId {"error":"A list must be selected.","code":"invalid_input"} 0
from_article + non-owned documentId {"error":"Document not found.","code":"invalid_input"} 0
research_url + ftp:// {"error":"Only http(s) URLs are supported.","code":"invalid_input"} 0

These render in an amber bar directly beneath the offending picker, not in the generic notice slot — #15's last acceptance criterion.

The attribution branches on mode + error code, never on the server's prose. That's sound rather than lucky: every client-knowable input problem (the 500-word powered_document cap, an empty topic, a missing reference, a non-http scheme) is already rejected locally before the call goes out, so an invalid_input that still comes back in a derived mode can only be the one thing the client cannot check — the reference doesn't exist, or it isn't this user's. The server's own wording is what gets displayed, since that's the specific and useful part.

Source pickers list the account's own lists plus root documents and documents filed in folders — a document someone tidied away is still a legitimate source. They load lazily, only once a mode needing them is selected, rather than spending two GETs on panel construction most sessions won't use.

One probe changed the mode picker's design

An unrecognized context.mode is not cheap-rejected. {"mode":"not_a_mode"} fell through to the model and came back 422 invalid_ai_output — billed a unit.

So mode is never a free string here. It's the enum end-to-end, and the picker can only ever produce one of the four documented values.

The rendered preview is actually rendered

"Rendered markdown preview, editable before confirming" needed a renderer, and the app has no markdown library (two NuGet refs total). MarkdownRenderer builds a FlowDocument covering what the server actually emits — ATX headings, fenced code, bullet/ordered lists, GFM pipe tables, blockquotes, rules, inline bold/italic/code/links — using only existing Strata theme brushes, so it follows light/dark and introduces no colour, font or radius of its own. Anything unrecognized degrades to a plain paragraph.

Pipe tables are in there because the live probe needed them. The returned draft contained:

| Date | Platform | Topic / Asset | Owner |
|------|----------|----------------|-------|
| TBD  | TBD      | TBD            | TBD   |

Without table support those three lines would have collapsed into one run-together paragraph of pipes and dashes — exactly the mangling a preview exists to prevent. I would not have known to build it without making the call.

The classification logic is tested. The line-level predicates live in their own MarkdownSyntax.cs with no WPF dependency, specifically so they can run off-Windows (anything touching FlowDocument cannot). A harness replays the real /suggest markdown through the renderer's actual index-advancing control flow — including the multi-line branches, so a table body row is counted as consumed by its table rather than reported as a stray paragraph — and asserts 25 properties. All pass:

PASS  the GFM table became one table block
PASS  table read as 4 columns with 1 body row
PASS  NO line fell through to a mangled pipe paragraph
PASS  no bogus hr (the |---| row must NOT be an hr)
PASS  '* * *' is an hr, not a bullet      PASS  '- item' is a bullet, not an hr
PASS  '#hashtag' is not a heading          PASS  7 hashes is not a heading
PASS  a paragraph with a stray pipe is not a table header
PASS  the italic caption stayed a paragraph, not a bullet
…25 passed, 0 failed

The rendered pane and the editable markdown box are two views of one string: the markdown is the single point of truth and the rendered pane projects it, so toggling can't lose an edit.

/suggest call made and the artifact shape

POST /api/ai/suggest · feature: powered_document, context: {mode: "from_article", documentId: <own doc>}, maxOutputTokens: 1400 → 200

{ "ok": true, "feature": "powered_document",
  "artifact": {
    "kind": "document",
    "title": "Social Media Campaign Briefing",
    "markdown": "# Social Media Campaign Briefing\n\n## Overview\n…",   // 1,289 chars
    "outline": ["Overview","Objectives","Target Audience","Channels & Formats",
                "Key Messages","Content Calendar","Metrics & Success Criteria"],
    "isPublic": false
  },
  "usage": { "inputTokens":238, "outputTokens":802, "model":"claude-sonnet-5" },
  "quota": { "usedToday":5, "dailyLimit":50 } }

The shape matches AiDocumentArtifact exactly as #137 transcribed it — {kind, title, markdown, outline[], isPublic} — so that model needed no change. maxOutputTokens is accepted. One call bought three things: the artifact shape, proof a derived IDOR-guarded mode passes with an owned reference, and the markdown constructs the renderer has to handle.

Quota units spent by this PR: 1 (usedToday 4 → 5).

Left unverified

  • POST /api/ai/generate was NOT called — it persists to a shared account. The confirm path is written defensively for that reason (read-after-write via GET /api/documents/{id}, and an explicit "don't retry, you'd duplicate and spend another credit" when no id comes back), but the {documentId} field name is unconfirmed. Whoever runs this against a real account first should verify it and tighten AiCreatedResources.
  • research_url was never exercised with a real URL. Only the free scheme rejection was. The server fetches and truncates the page behind an SSRF guard, so a successful fetch's effect on the artifact (the issue says it cites the URL) is unobserved — I didn't want to spend a unit making the server fetch a third-party page.
  • from_list was not exercised successfully — only its two free rejection paths. from_article was, and the two modes differ only in which id the server resolves, so the client-side path is identical; but the account's single list ("New list", no rows) would have been a poor test anyway.
  • The renderer has no test for FlowDocument construction — only for line classification. WPF cannot run on macOS, so the block-builder half (sizes, margins, brush lookups, table cell assembly) is compile-verified and reasoned, not executed. A Windows run is worth doing before merge.
  • article mode (the default) was never called. It's the simplest path — no context beyond mode — and shares everything downstream with the mode that was verified.

Builds clean in both -c Debug and -c Release.

Closes #15

🤖 Generated with Claude Code

Draft a full markdown document in any of the four documented context.mode
variants: article (default), from_list, from_article, research_url. Hosted in
DocumentsView.xaml with one line and no code-behind edit — #139 is also in that
file, so this uses the #55/#56 self-contained-control pattern like #14 does.

## A reference, not source text

from_list sends a listId, from_article a documentId, research_url a URL, and the
server loads or fetches the source itself. All three are IDOR-guarded. Verified
live, and all four probes were free — input validation costs no quota
(usedToday was unchanged across them):

  from_list    non-owned id -> {"error":"List not found.","code":"invalid_input"}
  from_list    no id        -> {"error":"A list must be selected.","code":"invalid_input"}
  from_article non-owned id -> {"error":"Document not found.","code":"invalid_input"}
  research_url ftp://       -> {"error":"Only http(s) URLs are supported.","code":"invalid_input"}

Those all land on SourceError — the amber bar directly beneath the offending
picker — rather than the generic notice slot, which is #15's last acceptance
criterion. The attribution branches on mode + error code, never on the server's
prose: every client-knowable input problem (the 500-word cap, an empty topic, a
missing reference, a non-http scheme) is already rejected locally before the
call leaves, so an invalid_input that still comes back in a derived mode can
only be the one thing the client cannot check — the reference doesn't exist or
isn't this user's. The server's own wording is what gets displayed, since it's
the specific and useful part.

The source pickers list the user's own lists plus root documents AND documents
filed in folders — a document someone tidied away is still a legitimate source.
They load lazily, only once a mode that needs them is selected, rather than
spending two GETs on panel construction that most sessions won't use.

## One live probe changed the mode picker's design

An unrecognized context.mode is NOT cheap-rejected: {"mode":"not_a_mode"} fell
through to the model and came back 422 invalid_ai_output — billed a quota unit.
So mode is never a free string here; it's the four-value AiDocumentMode enum
throughout, and the picker can only ever produce one of them.

## The rendered preview is actually rendered

"Rendered markdown preview" needed a renderer, and the app has no markdown
library. MarkdownRenderer builds a FlowDocument covering what the server
actually emits — ATX headings, fenced code, bullet and ordered lists, GFM pipe
tables, blockquotes, rules, and inline bold/italic/code/links — using only
existing Strata theme brushes, so it follows light/dark and introduces no
colour, font or radius of its own. Everything unrecognized degrades to a plain
paragraph, which is the right failure mode for a preview.

Pipe tables are in there because the live probe's draft contained one
(| Date | Platform | Topic / Asset | Owner |) in the middle of a briefing.
Without table support those three lines would have collapsed into a single
run-together paragraph of pipes and dashes — exactly the mangling a preview
exists to prevent.

The line-classification predicates live in their own MarkdownSyntax file with
no WPF dependency, specifically so they can be tested off-Windows: a harness
replays the real /suggest markdown through the renderer's actual
index-advancing control flow and asserts 25 properties, including that the
table becomes one 4-column block rather than three stray paragraphs, that the
|---| separator is not mistaken for a horizontal rule, that "* * *" is a rule
while "- item" is not, that "#hashtag" is not a heading, and that a paragraph
with a stray pipe is not a table. All 25 pass.

The rendered view and the editable markdown box are two views of one string:
the markdown is the single point of truth and the rendered pane projects it, so
toggling can't lose an edit.

## Quota discipline

Confirm pre-flights a non-empty title, non-empty body, and the 40,000-character
server ceiling before spending the /generate unit, and treats the returned
{documentId} as a hint — it re-fetches with GET /api/documents/{id} before
handing the host anything, and when no id comes back it says plainly that a
retry would duplicate the document and spend another credit.

/suggest calls made: 1 (powered_document, mode from_article, against the
account's own "Social Media Campaign" document). Artifact shape confirmed
exactly as modelled: {kind, title, markdown, outline[], isPublic}, with a
7-entry outline. POST /api/ai/generate was NOT called — it persists to a shared
account — so the confirm path is reasoned, not observed.

Closes #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron
Adron merged commit 804219f into main Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AI: Powered Document (4 modes: article / from list / from article / research URL)

1 participant