AI: Powered Document — all four modes, with a real rendered preview - #177
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack
Last of five stacked PRs on the AI epic. Based on #174, not
main:issue-9-ai-servicemainissue-10-ai-gatingissue-9-ai-serviceissue-14-powered-templatesissue-10-ai-gatingissue-15-powered-documentissue-14-powered-templatesMerge in that order.
Hosting: one line, zero code-behind
Views/DocumentsView.xamlis contended by #139, so this uses the same #55/#56 pattern as #174. The entire diff to the contended file is:dropped as the first child of the editor column's
Grid.Row="1"StackPanel. It collapses itself when #10'sIsAiAvailablegate 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 anyStackPanelwhoseDataContextisDocumentsViewModel.A reference, not source text — and the errors land on the field
All four modes go through the four-value
AiDocumentModeenum. The three derived ones are IDOR-guarded, and all four failure paths were verified live for free (usedTodayunchanged across all of them):from_list+ non-ownedlistId{"error":"List not found.","code":"invalid_input"}from_list+ nolistId{"error":"A list must be selected.","code":"invalid_input"}from_article+ non-owneddocumentId{"error":"Document not found.","code":"invalid_input"}research_url+ftp://{"error":"Only http(s) URLs are supported.","code":"invalid_input"}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_documentcap, an empty topic, a missing reference, a non-http scheme) is already rejected locally before the call goes out, so aninvalid_inputthat 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
So
modeis 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).
MarkdownRendererbuilds aFlowDocumentcovering 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:
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.cswith no WPF dependency, specifically so they can run off-Windows (anything touchingFlowDocumentcannot). A harness replays the real/suggestmarkdown 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: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.
/suggestcall made and the artifact shapePOST /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
AiDocumentArtifactexactly as #137 transcribed it —{kind, title, markdown, outline[], isPublic}— so that model needed no change.maxOutputTokensis 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 (
usedToday4 → 5).Left unverified
POST /api/ai/generatewas NOT called — it persists to a shared account. The confirm path is written defensively for that reason (read-after-write viaGET /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 tightenAiCreatedResources.research_urlwas 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_listwas not exercised successfully — only its two free rejection paths.from_articlewas, 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.FlowDocumentconstruction — 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.articlemode (the default) was never called. It's the simplest path — nocontextbeyondmode— and shares everything downstream with the mode that was verified.Builds clean in both
-c Debugand-c Release.Closes #15
🤖 Generated with Claude Code