From 5b36532c2e9f01a959d3b5525c30a6cbbe1db6cb Mon Sep 17 00:00:00 2001 From: trick77 Date: Thu, 17 Sep 2026 20:18:18 +0200 Subject: [PATCH 1/3] Answers draw more diagram types An answer's diagram is a mermaid fence chosen by the shape of the content: a flowchart for branches, a sequence for exchanges, a state machine, an entity model, or a trigger to target mapping drawn as two subgraphs. The two hand-written shapes forced a mapping into a flowchart with an invented order and a target drawn twice. The picture no longer cites per node; the sentence that introduces it carries the markers. That removes the reason for a hand-written layout, so the renderer, the chip placement and the src renumbering go. The renumberer treats the fence as code. Stored answers with the older JSON fence still draw through the existing conversion. The renderer runs with securityLevel strict, no bindFunctions, the theme read from the app's tokens, the classic look without shadows. Corpus tests now assert the fence leaves the backend byte for byte and passes the renderer's own parser. --- AGENTS.md | 4 +- README.md | 9 +- backend/internal/ask/answer.go | 130 +- backend/internal/ask/answer_test.go | 13 +- backend/internal/ask/diagram_test.go | 266 ++-- backend/internal/ask/diagramdoc_test.go | 149 --- backend/internal/ask/renumber.go | 471 +------ backend/internal/ask/renumber_corpus_test.go | 104 +- backend/internal/ask/swiss.go | 5 +- .../internal/ask/testdata/diagrams/README.md | 13 +- .../diagrams/code-then-diagram.golden | 13 + .../testdata/diagrams/code-then-diagram.txt | 13 + .../ask/testdata/diagrams/diagram-tag.golden | 9 + .../ask/testdata/diagrams/diagram-tag.txt | 9 + .../testdata/diagrams/directive-first.golden | 9 + .../ask/testdata/diagrams/directive-first.txt | 9 + .../ask/testdata/diagrams/er-model.golden | 18 + .../ask/testdata/diagrams/er-model.txt | 18 + .../diagrams/flowchart-decision.golden | 12 + .../testdata/diagrams/flowchart-decision.txt | 12 + .../ask/testdata/diagrams/json-fence.golden | 25 - .../ask/testdata/diagrams/json-fence.txt | 25 - .../diagrams/keys-reordered-no-fence.golden | 8 - .../diagrams/keys-reordered-no-fence.txt | 5 - .../testdata/diagrams/keys-reordered.golden | 7 - .../ask/testdata/diagrams/keys-reordered.txt | 7 - .../diagrams/labels-with-brackets.golden | 9 + .../diagrams/labels-with-brackets.txt | 9 + .../ask/testdata/diagrams/mapping-lr.golden | 25 + .../ask/testdata/diagrams/mapping-lr.txt | 25 + .../ask/testdata/diagrams/mermaid-tag.golden | 7 - .../ask/testdata/diagrams/mermaid-tag.txt | 7 - .../ask/testdata/diagrams/no-fence.golden | 21 - .../ask/testdata/diagrams/no-fence.txt | 18 - .../ask/testdata/diagrams/sequence-de.golden | 14 + .../ask/testdata/diagrams/sequence-de.txt | 14 + .../ask/testdata/diagrams/spaced-open.golden | 16 +- .../ask/testdata/diagrams/spaced-open.txt | 15 +- .../ask/testdata/diagrams/src-as-chain.golden | 8 - .../ask/testdata/diagrams/src-as-chain.txt | 8 - .../diagrams/src-as-comma-chain.golden | 8 - .../testdata/diagrams/src-as-comma-chain.txt | 8 - .../testdata/diagrams/state-machine.golden | 13 + .../ask/testdata/diagrams/state-machine.txt | 13 + .../testdata/diagrams/tagged-diagram.golden | 7 - .../ask/testdata/diagrams/tagged-diagram.txt | 7 - .../testdata/diagrams/wide-sequence.golden | 8 - .../ask/testdata/diagrams/wide-sequence.txt | 8 - .../internal/retrieve/eval/answers_test.go | 5 +- ui/package-lock.json | 1157 +++++++++++++++++ ui/package.json | 1 + ui/src/DiagramView.test.tsx | 152 +-- ui/src/DiagramView.tsx | 136 +- ui/src/RepoList.test.tsx | 14 +- ui/src/RepoList.tsx | 38 +- ui/src/corpus.test.ts | 43 +- ui/src/diagram.test.tsx | 517 ++------ ui/src/diagram.tsx | 821 ++++-------- ui/src/diagramExport.test.ts | 67 +- ui/src/diagramExport.ts | 127 +- ui/src/index.css | 11 + ui/src/markdown.test.tsx | 51 +- ui/src/markdown.tsx | 61 +- 63 files changed, 2200 insertions(+), 2632 deletions(-) delete mode 100644 backend/internal/ask/diagramdoc_test.go create mode 100644 backend/internal/ask/testdata/diagrams/code-then-diagram.golden create mode 100644 backend/internal/ask/testdata/diagrams/code-then-diagram.txt create mode 100644 backend/internal/ask/testdata/diagrams/diagram-tag.golden create mode 100644 backend/internal/ask/testdata/diagrams/diagram-tag.txt create mode 100644 backend/internal/ask/testdata/diagrams/directive-first.golden create mode 100644 backend/internal/ask/testdata/diagrams/directive-first.txt create mode 100644 backend/internal/ask/testdata/diagrams/er-model.golden create mode 100644 backend/internal/ask/testdata/diagrams/er-model.txt create mode 100644 backend/internal/ask/testdata/diagrams/flowchart-decision.golden create mode 100644 backend/internal/ask/testdata/diagrams/flowchart-decision.txt delete mode 100644 backend/internal/ask/testdata/diagrams/json-fence.golden delete mode 100644 backend/internal/ask/testdata/diagrams/json-fence.txt delete mode 100644 backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.golden delete mode 100644 backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.txt delete mode 100644 backend/internal/ask/testdata/diagrams/keys-reordered.golden delete mode 100644 backend/internal/ask/testdata/diagrams/keys-reordered.txt create mode 100644 backend/internal/ask/testdata/diagrams/labels-with-brackets.golden create mode 100644 backend/internal/ask/testdata/diagrams/labels-with-brackets.txt create mode 100644 backend/internal/ask/testdata/diagrams/mapping-lr.golden create mode 100644 backend/internal/ask/testdata/diagrams/mapping-lr.txt delete mode 100644 backend/internal/ask/testdata/diagrams/mermaid-tag.golden delete mode 100644 backend/internal/ask/testdata/diagrams/mermaid-tag.txt delete mode 100644 backend/internal/ask/testdata/diagrams/no-fence.golden delete mode 100644 backend/internal/ask/testdata/diagrams/no-fence.txt create mode 100644 backend/internal/ask/testdata/diagrams/sequence-de.golden create mode 100644 backend/internal/ask/testdata/diagrams/sequence-de.txt delete mode 100644 backend/internal/ask/testdata/diagrams/src-as-chain.golden delete mode 100644 backend/internal/ask/testdata/diagrams/src-as-chain.txt delete mode 100644 backend/internal/ask/testdata/diagrams/src-as-comma-chain.golden delete mode 100644 backend/internal/ask/testdata/diagrams/src-as-comma-chain.txt create mode 100644 backend/internal/ask/testdata/diagrams/state-machine.golden create mode 100644 backend/internal/ask/testdata/diagrams/state-machine.txt delete mode 100644 backend/internal/ask/testdata/diagrams/tagged-diagram.golden delete mode 100644 backend/internal/ask/testdata/diagrams/tagged-diagram.txt delete mode 100644 backend/internal/ask/testdata/diagrams/wide-sequence.golden delete mode 100644 backend/internal/ask/testdata/diagrams/wide-sequence.txt diff --git a/AGENTS.md b/AGENTS.md index 5144c6ac..e3fef79e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,8 +49,8 @@ Rules, not description. The code is truth — how a rule is implemented is disco - **Code is truth, docs are context.** A contradiction is named in the answer, which sides with the code. Docs are demoted in fusion by a decay, never filtered (`DocDecay = 0.7`; 0.5 costs 2 of 3 doc-led questions — `2026-09-05-doc-demotion.md`), doc-only modules stay off the card, and an all-docs turn says so above the answer and reports what the document states, not what the system does. Plans and mock-ups → `BACKEND_INDEX_EXCLUDE`, never a broad doc exclusion. - **Data is filtered, docs are not.** The selector skips by NAME first (`generated` in a basename or segment, a catalogue format under `i18n/` or `locales/`, `__snapshots__/`, generator suffixes, `.lock`/`.sum`), then by FORMAT (csv, source maps, snapshots, translation catalogues, svg), then json/xml above `BACKEND_INDEX_MAX_DATA_FILE_BYTES` (8 KiB, `2026-09-17-data-file-cap.md`; manifests exempt, yaml never capped). Every path-and-size rule is re-applied by `Indexer.Sweep` at boot, so a new rule retires what an older build embedded; body-only verdicts (secret, marker) are not. - **Re-sweeping `DocDecay` reads r@20 AND r@5 AND mean rank** (0.7 moved mean rank 2.56 → 2.40, flat below): membership at the cut cannot see a README going from rank 1 to 8. **Mean rank over the questions EVERY arm ranks**, never each arm's own hits — otherwise an arm admitting one more question at rank 19 reports a degradation that never happened. -- **A diagram cites like prose**: one fence per answer, every node lists its sources, same chips and same viewer. **A node cites code or nothing** — doc markers are stripped from a node before reader numbers are assigned, and the node is still drawn with no sources rather than dropped. The predicate is the file's spelling, not its ranking role, because a dropped chip is unrecoverable where a demotion only costs rank. A doc-only claim is named in the sentence instead. Undrawable spec → code block; an oversized one is drawn, never dropped. -- **The diagram trigger is audience-neutral and positive**: a branch or a second party earns the picture, said in the BA block in the reader's words (case, approval, hand-off) and never as the fence. "Only where control flow ..." plus "then stop" in the BA block drew 1 diagram in 19 BA answers; the rewrite draws 13 with rubric inside judge noise (`docs/measurements/2026-09-17-ba-diagrams.md`). `TestEvalMeasureAnswers` prints `diagram %q` per answer; a prompt change here is read against that number, never against a hunch. +- **A diagram is a `mermaid` fence and does not cite.** One per answer; the sentence that introduces it carries the markers. Type by content shape (`answerDiagram`): flowchart TD for branches, sequenceDiagram for exchanges, stateDiagram-v2 for states, erDiagram for entities, flowchart LR with two subgraphs for a trigger → target mapping (a target is ONE node; the flow renderer drew it twice and invented an order, share W5L1FYhvOF 2026-09-17). Every label in double quotes, no marker inside the fence: a bracket there is syntax and the strict parser drops the whole picture. The renumberer treats the fence as code (`renumber.go`); the browser draws it with `securityLevel: "strict"`, never `bindFunctions` (`ui/src/diagram.tsx`); the older `diagram` JSON fence of stored answers still draws through `toMermaid`. Corpus `backend/internal/ask/testdata/diagrams/`: `.txt` is the answer as written, Go asserts the fence leaves byte-identical, `ui/src/corpus.test.ts` asks the real `mermaid.parse`. A diagram that fails to draw goes in as a `.txt` FIRST. Swiss `ß` inside a label is a known gap (`swiss.go`). +- **The diagram trigger is audience-neutral and positive**: a branch or a second party earns the picture, said in the BA block in the reader's words (case, approval, hand-off) and never as the fence. "Only where control flow ..." plus "then stop" in the BA block drew 1 diagram in 19 BA answers; the rewrite draws 13 with rubric inside judge noise (`docs/measurements/2026-09-17-ba-diagrams.md`). `TestEvalMeasureAnswers` prints `diagram %q` per answer (the fence's type, `flowchart`/`sequenceDiagram`/...); a prompt change here is read against that number, never against a hunch. ### Threads - **The thread is a record.** A follow-up adds an answer, never rewrites one. A correction is a new question. diff --git a/README.md b/README.md index 016b738a..3b4e3bff 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ ![Rongo](rongo-wide.jpg) Turns a codebase into something the rest of the company can ask. Plain-language -questions, answers in domain terms, flowcharts and sequence diagrams where the -process is the point. +questions, answers in domain terms, a diagram where the shape is the point. Rongo is a web app. It runs on a server, clones the repositories you list, indexes them, and answers questions about them in a browser. No install, no @@ -19,8 +18,10 @@ the places the answer was read from](rongo-app.png) what happens to a customer record on close, and Rongo reads the code that matched and explains the mechanism. -**A diagram where the flow is the answer.** A process worth following step by step -comes back as a flowchart, an exchange between services as a sequence diagram. +**A diagram where the shape is the answer.** A process worth following step by step +comes back as a flowchart, an exchange between services as a sequence diagram, a +set of states as a state machine, a data model as an entity diagram, and which +event feeds which topic as a mapping. **Somewhere to look.** Every claim shows where it was read, and one click opens that file inside Rongo, at the state it was indexed at. diff --git a/backend/internal/ask/answer.go b/backend/internal/ask/answer.go index 7b857c75..98414f2f 100644 --- a/backend/internal/ask/answer.go +++ b/backend/internal/ask/answer.go @@ -438,7 +438,8 @@ process listed here whose nodes have no source of their own may be walked by the names above, and then say in a clause that its model was not among the sources; say nothing about what its steps do inside. This walk is the flowchart: draw it as the answer's diagram, one node per step above, the -branch condition as the edge label, each node citing the step's own source.` +branch condition as the edge label, the steps' sources cited in the sentence +that introduces it.` // answerLinks is the link census block. The listing is read from the index, // so it is complete where the sources are capped; what the sites lead to is @@ -539,73 +540,84 @@ var answerIntent = map[string]string{ // are the ones the model just read: a Developer diagram names functions and // files, an Analyst diagram speaks the domain. The fence is named literally, // as the DEV block names ` + "```go" + `: the model needs the syntax, not a -// description of it. The src array cites the same sources the prose does, -// but it is JSON: answerCommon's "one marker per bracket" reads as [6][25] -// applied to an array, which is not JSON at all, so the fence says outright -// that the rule stops here - both the bare chain and the [6],[25] the model -// writes when it remembers the comma but not the single array. +// description of it. // // The trigger is positive and audience-neutral on purpose. It used to read // "only where control flow or a call sequence carries the explanation", // which is a restriction in developer words: an Analyst question is about a // process, a hand-off, a decision, and the words never fired. Saying what -// earns a picture - an order with a branch, or two parties exchanging -// messages - and what does not (a plain sequence of steps is a list, one +// earns a picture and what does not (a plain sequence of steps is a list, one // rule or value is prose) draws the line the shape rule's list permission // otherwise claims for itself. // -// The counts below are what reads well, and the renderer no longer enforces -// them: a spec one actor too wide used to be dropped and shown as its JSON, -// which is worse than a wide picture in a box that scrolls. They stay strict -// here because that is what keeps a diagram compact in the first place. +// The type table is the reason the renderer changed. With two shapes on +// offer, a question whose answer is a mapping - which trigger produces to +// which topic, which handler serves which route - came back as a flowchart +// with an invented order between the targets and one target drawn twice +// (share W5L1FYhvOF, 2026-09-17). Each row names the shape of the content +// and the type that draws it; the mapping row says outright that a target is +// one node, because that is the error the flowchart made. +// +// The picture does not cite. It used to, through a src array per node, and +// the chips were the reason the renderer was hand-written; the sentence that +// introduces the diagram carries the markers now, as a doc-only claim +// already does. Inside the fence a bracket is syntax, so a marker there +// breaks the picture, and the rule says so. +// +// The label rule exists because with nothing said about length the model +// wrote sentences ("RANK 1: gefundene Code-Fragmente nach Relevanz zur Frage +// bewerten") and the reader got boxes of half-sentences, seen 2026-09-16. +// Quotes around every label are what keeps a label with a parenthesis, a +// colon or an umlaut from being read as syntax: the renderer's parser is +// strict and a parse error loses the whole picture. // // The closing paragraph settles a contradiction the model was left to resolve // on its own: answerBA bars source code from an Analyst answer, and a diagram -// is syntactically a code fence, so the block came back tagged ```json or with -// no fence at all and the reader got JSON. The prompt is not the guard, -// though - renumber.go recognises the spec by its content whatever the fence -// says, because a prompt this one only ever holds most of the time. -// -// The label rule exists because the count cap alone did not keep a node -// short: with nothing said about length the model wrote sentences ("RANK 1: -// gefundene Code-Fragmente nach Relevanz zur Frage bewerten"), and the -// renderer's box is fixed at about twenty columns by three lines, cutting -// the rest with an ellipsis (ui/src/diagram.tsx, wrap). The reader then got -// a diagram of half-sentences, seen 2026-09-16. The cut is deliberate on the -// renderer's side, so the fix is here: a node is a title. +// is syntactically a code fence, so the block came back tagged as something +// else or with no fence at all and the reader got text. const answerDiagram = ` At most one diagram. Draw one when the explanation is steps in an order with -a decision that splits the path (a flowchart, "type":"flow"), or two or more -parties exchanging messages in an order (a sequence diagram, -"type":"sequence") - whether the parties are functions and services or -roles, systems and people. Steps with no branch and one party are a list, -not a diagram. Skip it when the answer is one rule, one value or one place. -The diagram is a fenced block tagged ` + "```diagram" + ` holding JSON, either -{"type":"flow","nodes":[{"id","label","kind":"start|end|step|decision","src":[1]}], - "edges":[{"from","to","label"}]} or -{"type":"sequence","actors":[{"id","label"}], - "steps":[{"from","to","label","kind":"call|return|async","src":[1]}]}. -src holds the markers the node rests on. It is a JSON array, not prose: two -sources read "src":[6,25], never "src":[6][25] or "src":[6],[25] - the -one-marker-per-bracket rule is about running text and does not reach inside -the fence. At most 12 nodes, 5 actors, 12 steps. Labels follow the audience -rules above and are written in the answer language; ids stay short ASCII. -A label is a title, not a sentence: a noun phrase or an imperative of at -most five words, about 40 characters - "Treffer bewerten", not "RANK 1: -gefundene Code-Fragmente nach Relevanz zur Frage bewerten". No step -numbers, no "RANK 1:" prefixes, no clauses: the box holds three short lines -and cuts the rest with an ellipsis, so a long label reaches the reader -truncated. An actor gets one line of about 15 characters, one or two words: -"Indexer", not "Indexing Service Backend". What a node cannot say in five -words goes in the prose. -The prose still explains; the diagram is not a substitute. +a decision that splits the path; two or more parties exchanging messages in +an order, whether the parties are functions and services or +roles, systems and people; a set of states with the transitions between +them; entities with the relations between them; or a mapping from triggers +to targets. +Steps with no branch and one party are a list, not a diagram. Skip it when +the answer is one rule, one value or one place. +The diagram is a fenced block tagged ` + "```mermaid" + ` holding mermaid +syntax, and its type follows the shape of the content: +- steps with a branch: flowchart TD, a decision as a {"?"} node, the + condition as the edge label +- an exchange between parties over time: sequenceDiagram, one participant + per party +- states and transitions: stateDiagram-v2, the guard on the transition +- entities and their relations: erDiagram, with the fields that matter +- a mapping from triggers to targets (which event produces to which topic, + which handler serves which route): flowchart LR with two subgraphs, the + triggers in one and the targets in the other, an edge per pair and the + condition as its label. A target that several triggers reach is ONE node + with several edges into it, never drawn twice, and there is no edge between + two targets: a mapping has no order. +At most 12 nodes, 5 participants, 12 messages. Every node label sits in +double quotes - a["Treffer bewerten"], b{"Zeiträume vorhanden?"} - because +a parenthesis, a colon or a bracket outside quotes is syntax and breaks the +picture; a sequence message after the colon needs no quotes. A label is a +title, not a sentence: a noun phrase or an imperative of at most five words, +about 40 characters - "Treffer bewerten", not "RANK 1: gefundene +Code-Fragmente nach Relevanz zur Frage bewerten". No step numbers, no +"RANK 1:" prefixes, no clauses. Labels follow the audience rules above and +are written in the answer language; ids stay short ASCII without spaces. +What a node cannot say in five words goes in the prose. +No citation markers inside the fence: the sentence that introduces the +diagram carries the markers for what it shows, and the prose still explains; +the diagram is not a substitute. The block is a diagram, not source code: an audience rule that bars code, signatures or file paths from running text does not bar it, and it is written -for every audience. Open it with ` + "```diagram" + ` and nothing else - not -` + "```json" + `, not ` + "```mermaid" + `, and never as bare JSON without a -fence. A block opened any other way is printed as text and the reader gets no +for every audience. Open it with ` + "```mermaid" + ` and nothing else - not +` + "```json" + `, not ` + "```diagram" + `, and never without a fence. A +block opened any other way is printed as text and the reader gets no picture.` // nothingFound is the answer when nothing was gathered, in the language the @@ -785,21 +797,6 @@ func DocsOnly(sources []Source) bool { return true } -// docMask says, per source, whether it is documentation. It is what lets the -// renumberer keep a diagram node from citing prose; the order is the order -// renderSources numbered, so index i is the prompt's source i+1. -// -// IsProseDoc, not IsDocPath: this drops a citation outright rather than -// demoting it, so a file that is code sitting in a docs/ directory keeps its -// chip. See IsProseDoc for why the two predicates differ. -func docMask(sources []Source) []bool { - out := make([]bool, len(sources)) - for i, s := range sources { - out[i] = retrieve.IsProseDoc(s.Path) - } - return out -} - // scopeNotice is the "one of the repositories you named is not indexed" // sentence, in the language the reader asked for. Templated rather than // written by a model, exactly like nothingFound: a person reads it, so the @@ -1151,7 +1148,6 @@ func (a *Answerer) Answer(ctx context.Context, question string, audience Audienc // way on the same pass (swiss.go), so the record is what was read. var text strings.Builder rn := newRenumberer(len(sources)) - rn.docs = docMask(sources) sp := &speller{} if ParseLanguage(string(lang)) == LanguageDE { rn.spell = sp.prose diff --git a/backend/internal/ask/answer_test.go b/backend/internal/ask/answer_test.go index 5e4a54e3..64b2a23f 100644 --- a/backend/internal/ask/answer_test.go +++ b/backend/internal/ask/answer_test.go @@ -329,13 +329,16 @@ func TestAnswer_theAudienceReachesThePrompt(t *testing.T) { t.Error("the BA prompt talks about fenced code, but a BA answer carries none") } for name, p := range map[string]string{"BA": *promptBA, "DEV": *promptDev} { - if !strings.Contains(p, "```diagram") || !strings.Contains(p, `"src":[1]`) { - t.Errorf("the %s prompt does not name the diagram fence and its src arrays", name) + if !strings.Contains(p, "```mermaid") || !strings.Contains(p, "flowchart LR with two subgraphs") { + t.Errorf("the %s prompt does not name the diagram fence and its types", name) + } + if !strings.Contains(p, "No citation markers inside the fence") { + t.Errorf("the %s prompt lets a marker into the fence, where a bracket is syntax", name) } } // The diagram rule follows the audience block, so "the audience rules // above" are the ones the model just read. - if strings.Index(*promptBA, "Audience: business analyst") > strings.Index(*promptBA, "```diagram") { + if strings.Index(*promptBA, "Audience: business analyst") > strings.Index(*promptBA, "```mermaid") { t.Error("the diagram rule comes before the audience block it refers to") } // The BA block names the picture in the reader's words, inside the @@ -376,7 +379,7 @@ func TestAnswer_theAudienceReachesThePrompt(t *testing.T) { if strings.Index(p, "Open with ONE sentence") < strings.Index(p, audience) { t.Errorf("the %s shape rules come before the audience block", name) } - if strings.Index(p, "Open with ONE sentence") > strings.Index(p, "```diagram") { + if strings.Index(p, "Open with ONE sentence") > strings.Index(p, "```mermaid") { t.Errorf("the %s shape rules come after the conditional blocks", name) } } @@ -415,7 +418,7 @@ func TestAnswer_theIntentSharpensTheOpeningSentence(t *testing.T) { if strings.Index(*prompt, want) < strings.Index(*prompt, "Open with ONE sentence") { t.Errorf("the %q rule comes before the shape rules it refines", intent) } - if strings.Index(*prompt, want) > strings.Index(*prompt, "```diagram") { + if strings.Index(*prompt, want) > strings.Index(*prompt, "```mermaid") { t.Errorf("the %q rule comes after the conditional blocks", intent) } } diff --git a/backend/internal/ask/diagram_test.go b/backend/internal/ask/diagram_test.go index fdd6c628..71bb882d 100644 --- a/backend/internal/ask/diagram_test.go +++ b/backend/internal/ask/diagram_test.go @@ -1,14 +1,13 @@ package ask import ( - "encoding/json" "strings" "testing" ) -// A diagram fence cites through the src arrays of its nodes. Those numbers -// are the same claim a marker in prose is, so they pass through the same -// renumberer and land in the same evidence panel. +// A diagram fence is a code fence to the renumberer: nothing in it is a +// marker, and nothing in it is rewritten. The picture cites through the +// sentence that introduces it, and that sentence renumbers as prose does. // renumbered runs the whole answer through the renumberer in one go. func renumbered(t *testing.T, sources int, text string) (string, *renumberer) { @@ -18,111 +17,68 @@ func renumbered(t *testing.T, sources int, text string) (string, *renumberer) { return out, rn } -func TestRenumber_aDiagramNodeCitesThroughItsSrc(t *testing.T) { - text := "Prose without markers.\n```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"NewGrant","src":[2]},` + - `{"id":"b","label":"issueGrant","src":[1]}],"edges":[]}` + - "\n```\n" +const kafkaMap = "flowchart LR\n" + + " subgraph Ereignis\n" + + " e1[\"Schadenmeldung eingegangen\"]\n" + + " e2[\"Nachreichung eingegangen\"]\n" + + " end\n" + + " subgraph Topic\n" + + " t1[\"Benachrichtigung\"]\n" + + " t2[\"Arbeitsunfähigkeit\"]\n" + + " end\n" + + " e1 --> t1\n" + + " e2 --> t1\n" + + " e2 -->|\"nur mit Zeiträumen\"| t2\n" - out, rn := renumbered(t, 2, text) - - // First appearance decides the reader's number: source 2 is cited first. - if !strings.Contains(out, `"src":[1]`) || !strings.Contains(out, `"src":[2]`) { - t.Errorf("out = %q, want the src arrays renumbered 1 then 2", out) - } - cits := rn.citations(twoSources()) - if len(cits) != 2 || cits[0].Path != "backend/internal/httpapi/grant.go" { - t.Errorf("citations = %+v, want both, the reader's [1] being prompt source 2", cits) - } -} - -func TestRenumber_aDiagramAndTheProseShareOneNumbering(t *testing.T) { - // The invariant the feature rests on: a chip on a node is the chip in - // the prose, so the same source may not read [1] in one and [2] in the - // other. - text := "The grant is created in store.go [1].\n```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[2]},{"id":"b","label":"y","src":[1]}],"edges":[]}` + - "\n```" - - out, rn := renumbered(t, 2, text) - - if !strings.Contains(out, "store.go [1]") { - t.Errorf("out = %q, want the prose marker to stay the reader's [1]", out) - } - if !strings.Contains(out, `"src":[2]`) || !strings.Contains(out, `"src":[1]`) { - t.Errorf("out = %q, want the node on source 1 to read [1], as the prose does", out) - } - if len(rn.citations(twoSources())) != 2 { - t.Errorf("citations = %+v, want one row per source", rn.citations(twoSources())) - } -} - -func TestRenumber_anIndexExpressionInADiagramLabelIsNotACitation(t *testing.T) { - // Anchored on the "src" key, never on a bracket: a label is text. - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"parts[2]","src":[]}],"edges":[]}` + - "\n```" +func TestRenumber_aDiagramFenceIsLeftExactlyAsItCame(t *testing.T) { + text := "Die Zuordnung steht im Code [7][3].\n\n```mermaid\n" + kafkaMap + "```\n\nDanach [7]." - out, rn := renumbered(t, 2, text) + out, rn := renumbered(t, 9, text) - if !strings.Contains(out, `"label":"parts[2]"`) { - t.Errorf("out = %q, want the label untouched", out) - } - if len(rn.citations(twoSources())) != 0 { - t.Errorf("citations = %+v, want none: parts[2] is a label, not a claim", rn.citations(twoSources())) + if !strings.Contains(out, "```mermaid\n"+kafkaMap+"```") { + t.Errorf("out = %q, want the fence byte for byte as it came", out) } -} - -func TestRenumber_aCodeFenceNextToADiagramStillCitesNothing(t *testing.T) { - text := "```go\nx := a[1]\n// \"src\":[2]\n```\n```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[2]}],"edges":[]}` + - "\n```" - - out, rn := renumbered(t, 2, text) - - if !strings.Contains(out, "x := a[1]") || !strings.Contains(out, "// \"src\":[2]") { - t.Errorf("out = %q, want the go fence untouched, src-shaped comment included", out) + if !strings.HasPrefix(out, "Die Zuordnung steht im Code [1][2].") || !strings.HasSuffix(out, "Danach [1].") { + t.Errorf("out = %q, want the prose around it renumbered", out) } - cits := rn.citations(twoSources()) - if len(cits) != 1 || cits[0].Marker != 1 { - t.Errorf("citations = %+v, want only the diagram's source, as the reader's [1]", cits) + if len(rn.order) != 2 { + t.Errorf("order = %v, want the two prose sources and nothing from the fence", rn.order) } } -func TestRenumber_anInventedNumberInSrcIsLeftAloneAndNeverCited(t *testing.T) { - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[1,9]}],"edges":[]}` + - "\n```" +func TestRenumber_aNumberInADiagramLabelIsNotACitation(t *testing.T) { + // A label may hold anything: an index expression, a step number, a + // marker the model wrote against the prompt. None of it is a claim. + text := "```mermaid\nflowchart TD\n a[\"parts[2]\"] --> b[\"Schritt [1]\"]\n```" out, rn := renumbered(t, 2, text) - if !strings.Contains(out, `"src":[1,9]`) { - t.Errorf("out = %q, want 9 left as it came", out) + if out != text { + t.Errorf("out = %q, want %q", out, text) } - if len(rn.citations(twoSources())) != 1 { - t.Errorf("citations = %+v, want only the real source", rn.citations(twoSources())) + if len(rn.order) != 0 { + t.Errorf("order = %v, want nothing cited", rn.order) } } -func TestRenumber_aDiagramSplitAcrossTokensStillRenumbers(t *testing.T) { - // The normal case: the fence, the key and the array all arrive in - // pieces, and a half-written src may not reach the reader as it came. - rn := newRenumberer(2) - var got strings.Builder - for _, tok := range []string{"Text.\n``", "`diag", "ram\n{\"nodes\":[{\"sr", "c\":[", "2", "]},{\"src\"", ": [1", ", 2]}]}\n``", "`\n"} { - got.WriteString(rn.feed(tok)) - } - got.WriteString(rn.flush()) - - out := got.String() - if !strings.Contains(out, `"src":[1]`) { - t.Errorf("out = %q, want the first src renumbered to the reader's [1]", out) - } - if !strings.Contains(out, `"src":[1,2]`) { - t.Errorf("out = %q, want the grouped src renumbered and sorted", out) - } - if !strings.HasPrefix(out, "Text.\n```diagram\n") { - t.Errorf("out = %q, want the fence header intact", out) +func TestRenumber_aDiagramSplitAcrossTokensIsStillOneFence(t *testing.T) { + text := "Sequenz [4].\n```mermaid\nsequenceDiagram\n A->>B: call [4]\n B-->>A: ok\n```\nEnde [2]." + want := "Sequenz [1].\n```mermaid\nsequenceDiagram\n A->>B: call [4]\n B-->>A: ok\n```\nEnde [2]." + + for _, size := range []int{1, 2, 3, 5, 7, 11} { + rn := newRenumberer(5) + var out strings.Builder + for i := 0; i < len(text); i += size { + end := i + size + if end > len(text) { + end = len(text) + } + out.WriteString(rn.feed(text[i:end])) + } + out.WriteString(rn.flush()) + if out.String() != want { + t.Errorf("token size %d: out = %q, want %q", size, out.String(), want) + } } } @@ -140,116 +96,42 @@ func TestRenumber_aSameLineTripleBacktickSpanIsNotAFence(t *testing.T) { } } -func TestRenumber_theInfoStringIsReadAsTheBrowserReadsIt(t *testing.T) { - // markdown.tsx takes the first token of the info string, so "```diagram - // flow" still draws a diagram. Were this end stricter, its src would keep - // the prompt's numbering while the chip claimed the reader's - a chip - // opening a source the node never cited. - text := "```diagram flow\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[2]}],"edges":[]}` + - "\n```" - - out, rn := renumbered(t, 2, text) - - if !strings.Contains(out, `"src":[1]`) { - t.Errorf("out = %q, want the src renumbered despite the trailing word", out) - } - if len(rn.order) != 1 { - t.Errorf("order = %v, want the node's source cited", rn.order) - } -} - -func TestRenumber_aChainedSrcBecomesTheArrayItMeant(t *testing.T) { - // answerCommon tells the model that two sources read [6][25], and it - // applies that inside the fence. Written into JSON the chain does not - // parse, so the browser shows the diagram as the code block it now is - - // and renumbering only the first group would leave the second at prompt - // numbering, putting a wrong source under a chip. - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[2][1]}],"edges":[]}` + - "\n```" - - out, rn := renumbered(t, 2, text) - - if !strings.Contains(out, `"src":[1,2]`) { - t.Errorf("out = %q, want one array holding both, each renumbered", out) - } - if len(rn.order) != 2 { - t.Errorf("order = %v, want both sources cited, not one of them twice", rn.order) - } -} - -func TestRenumber_aChainedSrcSplitAcrossTokensIsStillOneArray(t *testing.T) { - // The group is complete where the token ends, so it may not be emitted - // yet: the bracket that follows turns it into a chain. - rn := newRenumberer(2) - var got strings.Builder - for _, tok := range []string{"```diagram\n{\"nodes\":[{\"src\":[2]", "[1]}]}\n```"} { - got.WriteString(rn.feed(tok)) - } - got.WriteString(rn.flush()) - - if out := got.String(); !strings.Contains(out, `"src":[1,2]`) { - t.Errorf("out = %q, want the chain joined across the token boundary", out) - } -} - -func TestRenumber_aChainedSrcLeavesTheDiagramParseable(t *testing.T) { - // The whole answer as one payload: what the reader must get back is a - // spec the browser's parseDiagram accepts, not a code block. - text := "```diagram\n" + - `{"type":"sequence","actors":[{"id":"ui","label":"UI"},{"id":"be","label":"Backend"}],` + - `"steps":[{"from":"ui","to":"be","label":"Get current rates","kind":"call","src":[2][1]}]}` + - "\n```" - - out, _ := renumbered(t, 2, text) - - body, _, _ := strings.Cut(strings.TrimPrefix(out, "```diagram\n"), "\n```") - var spec any - if err := json.Unmarshal([]byte(body), &spec); err != nil { - t.Errorf("the fence body does not parse: %v\nbody = %s", err, body) - } -} - func TestRenumber_anUnclosedDiagramFenceStillEndsWhole(t *testing.T) { // A cut stream: the browser shows the block as text, so nothing may be // held back for a close that never comes. rn := newRenumberer(2) - out := rn.feed("```diagram\n{\"nodes\":[{\"src\":[2") + rn.flush() + out := rn.feed("```mermaid\nflowchart TD\n a --> b[\"x") + rn.flush() - if !strings.Contains(out, `"src":[2`) { - t.Errorf("out = %q, want the partial array flushed as it came", out) + if !strings.HasSuffix(out, "b[\"x") { + t.Errorf("out = %q, want the partial fence flushed as it came", out) } } -// DiagramKind is what the answers harness counts: the picture the reader -// gets, after the renumberer has retagged whatever the model fenced it as, -// under the browser's own conditions (diagram.tsx parse, markdown.tsx -// fenceRe). -func TestDiagramKind_countsTheDrawnPictureOnly(t *testing.T) { - flow := `{"type":"flow","nodes":[{"id":"a","label":"Start","kind":"start"}],"edges":[]}` - seq := `{"type":"sequence","actors":[{"id":"u","label":"User"}],"steps":[]}` +// DiagramKind is what the answers harness counts: the diagram type the fence +// names, under the browser's reading of a fence (markdown.tsx fenceRe, +// diagram.tsx diagramKind). +func TestDiagramKind_readsTheTypeOffTheFence(t *testing.T) { for name, tc := range map[string]struct{ text, want string }{ - "flow": {"Lead.\n```diagram\n" + flow + "\n```\nMore.", "flow"}, - "sequence": {"```diagram\n" + seq + "\n```", "sequence"}, - // The browser takes the first token of the info string. - "header variant": {"```diagram flow\n" + flow + "\n```", "flow"}, + "flowchart": {"Lead.\n```mermaid\n" + kafkaMap + "```\nMore.", "flowchart"}, + "sequence": {"```mermaid\nsequenceDiagram\n A->>B: x\n```", "sequenceDiagram"}, + "state": {"```mermaid\nstateDiagram-v2\n [*] --> A\n```", "stateDiagram-v2"}, + "er": {"```mermaid\nerDiagram\n A ||--o{ B : has\n```", "erDiagram"}, + // A directive or a comment before the type is skipped. + "directive": {"```mermaid\n%%{init: {}}%%\n\nflowchart TD\n a\n```", "flowchart"}, + // The browser takes the first token of the info string, and the + // older tag still opens a diagram. + "header variant": {"```mermaid graph\nflowchart TD\n a\n```", "flowchart"}, + "diagram tag": {"```diagram\nflowchart TD\n a\n```", "flowchart"}, // A code fence before the diagram is skipped, not mistaken for it. - "after code": {"```go\nx := 1\n```\n```diagram\n" + seq + "\n```", "sequence"}, + "after code": {"```go\nx := 1\n```\n```mermaid\nerDiagram\n A\n```", "erDiagram"}, "prose": {"Just prose [1].", ""}, "code": {"```go\nx := 1\n```", ""}, - // The browser draws nothing from these and shows the block as text: - // a type it has no renderer for, no node or actor to draw, JSON that - // does not parse, a fence the stream cut off before the close. - "unknown type": {"```diagram\n{\"type\":\"pie\"}\n```", ""}, - "empty nodes": {"```diagram\n{\"type\":\"flow\",\"nodes\":[],\"edges\":[]}\n```", ""}, - "empty actors": {"```diagram\n{\"type\":\"sequence\",\"actors\":[],\"steps\":[]}\n```", ""}, - "bad json": {"```diagram\n{\"type\":\"flow\",\"nodes\":[{\"id\":\"a\",\n```", ""}, - "unclosed": {"```diagram\n" + flow, ""}, - // A json fence is what the renumberer retags on the way through; - // text that still carries it after the renumberer is a developer's - // quote of the format and stays a code block. - "json fence": {"```json\n" + flow + "\n```", ""}, + "empty": {"```mermaid\n\n```", ""}, + // The older JSON spec under either tag: the browser converts it, but + // the harness counts the shape the model wrote, and it wrote none. + "legacy json": {"```diagram\n{\"type\":\"flow\",\"nodes\":[]}\n```", ""}, + "json as mermaid": {"```mermaid\n{\"type\":\"flow\"}\n```", ""}, + "unclosed": {"```mermaid\nflowchart TD\n a", ""}, } { if got := DiagramKind(tc.text); got != tc.want { t.Errorf("%s: DiagramKind = %q, want %q", name, got, tc.want) diff --git a/backend/internal/ask/diagramdoc_test.go b/backend/internal/ask/diagramdoc_test.go deleted file mode 100644 index d327d548..00000000 --- a/backend/internal/ask/diagramdoc_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package ask - -import ( - "strings" - "testing" -) - -// A node of a diagram is a place in the mechanism, and a document is not one. -// A chip that opens a README under a step of the control flow says the step -// was read there. The markers are dropped rather than the node: no citation -// is better than a citation naming prose. - -// docAndCode is a README followed by the file it describes, so a fixture can -// cite either and say which one it meant. -func docAndCode() []Source { - return []Source{ - {ChunkID: 1, Repo: "peeq", Branch: "master", Path: "README.md", - StartLine: 1, EndLine: 40, Text: "A grant is issued per playback.", Reason: "hit"}, - {ChunkID: 2, Repo: "peeq", Branch: "master", Path: "backend/internal/playbackgrant/store.go", - StartLine: 1, EndLine: 30, Text: "func NewGrant() {}", Reason: "hit"}, - } -} - -// masked builds the renumberer the way the answer path does: with the mask -// that says which of its sources are documentation. -func masked(t *testing.T, sources []Source) *renumberer { - t.Helper() - rn := newRenumberer(len(sources)) - rn.docs = docMask(sources) - return rn -} - -func TestRenumber_aNodeRestingOnlyOnDocumentationCitesNothing(t *testing.T) { - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"Grant","src":[1]},` + - `{"id":"b","label":"NewGrant","src":[2]}],"edges":[]}` + - "\n```\n" - - rn := masked(t, docAndCode()) - out := rn.feed(text) + rn.flush() - - if !strings.Contains(out, `"label":"Grant","src":[]`) { - t.Errorf("out = %q, want the README node drawn with an empty src", out) - } - // The code node is the reader's [1]: the dropped marker never took a - // number, so the numbering skips nothing. - if !strings.Contains(out, `"label":"NewGrant","src":[1]`) { - t.Errorf("out = %q, want the code node numbered [1]", out) - } - cits := rn.citations(docAndCode()) - if len(cits) != 1 || cits[0].Path != "backend/internal/playbackgrant/store.go" { - t.Errorf("citations = %+v, want only the code source", cits) - } -} - -func TestRenumber_aNodeOnBothKeepsOnlyTheCode(t *testing.T) { - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"NewGrant","src":[1,2]}],"edges":[]}` + - "\n```\n" - - rn := masked(t, docAndCode()) - out := rn.feed(text) + rn.flush() - - if !strings.Contains(out, `"src":[1]`) { - t.Errorf("out = %q, want the README marker dropped and the code kept", out) - } - if cits := rn.citations(docAndCode()); len(cits) != 1 { - t.Errorf("citations = %+v, want the README uncited", cits) - } -} - -func TestRenumber_theProseStillCitesTheDocument(t *testing.T) { - // The rule is about the picture, not about the answer. A claim resting - // on a document is made in prose, where answerCommon has the model name - // the document in the sentence that makes it. - text := "The README states it [1].\n```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[1]}],"edges":[]}` + - "\n```\n" - - rn := masked(t, docAndCode()) - out := rn.feed(text) + rn.flush() - - if !strings.Contains(out, "README states it [1]") { - t.Errorf("out = %q, want the prose marker untouched", out) - } - if !strings.Contains(out, `"src":[]`) { - t.Errorf("out = %q, want the node to carry no chip", out) - } - cits := rn.citations(docAndCode()) - if len(cits) != 1 || cits[0].Path != "README.md" { - t.Errorf("citations = %+v, want the README, cited by the prose", cits) - } -} - -func TestRenumber_aDocumentBesideAnInventedNumberIsStillDropped(t *testing.T) { - // This array takes the fallback branch, where the numbers are rewritten - // where they stand because one of them is invented. A filter living - // inside the sorted branch would leave the README chip on the node. - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[1, 9]}],"edges":[]}` + - "\n```\n" - - rn := masked(t, docAndCode()) - out := rn.feed(text) + rn.flush() - - if strings.Contains(out, `"src":[1`) { - t.Errorf("out = %q, want no reader number: the only real source was the README", out) - } - if cits := rn.citations(docAndCode()); len(cits) != 0 { - t.Errorf("citations = %+v, want none", cits) - } -} - -func TestRenumber_aSequenceStepDropsItsDocumentationToo(t *testing.T) { - text := "```diagram\n" + - `{"type":"sequence","actors":[{"id":"a","label":"Reader"},{"id":"b","label":"Store"}],` + - `"steps":[{"from":"a","to":"b","label":"NewGrant","kind":"call","src":[1]}]}` + - "\n```\n" - - rn := masked(t, docAndCode()) - out := rn.feed(text) + rn.flush() - - if !strings.Contains(out, `"src":[]`) { - t.Errorf("out = %q, want the step to carry no chip", out) - } -} - -func TestRenumber_withoutAMaskNothingIsDropped(t *testing.T) { - // The corpus and the older diagram tests construct with a count alone. A - // renumberer that was never told what its sources are may not guess. - text := "```diagram\n" + - `{"type":"flow","nodes":[{"id":"a","label":"x","src":[1]}],"edges":[]}` + - "\n```\n" - - rn := newRenumberer(2) - out := rn.feed(text) + rn.flush() - - if !strings.Contains(out, `"src":[1]`) { - t.Errorf("out = %q, want the marker kept", out) - } -} - -func TestDocMask_marksTheDocumentsAndNothingElse(t *testing.T) { - got := docMask(docAndCode()) - - if len(got) != 2 || !got[0] || got[1] { - t.Errorf("docMask = %v, want the README marked and the code not", got) - } -} diff --git a/backend/internal/ask/renumber.go b/backend/internal/ask/renumber.go index cb6cbd22..c9c06fbc 100644 --- a/backend/internal/ask/renumber.go +++ b/backend/internal/ask/renumber.go @@ -1,7 +1,6 @@ package ask import ( - "encoding/json" "regexp" "sort" "strconv" @@ -36,19 +35,7 @@ type renumberer struct { dense map[int]int // the prompt's number -> the reader's order []int // the reader's number - 1 -> the prompt's pending string // what cannot be decided yet - // lastOut is the last byte handed to the reader, so a fence minted - // around a bare spec knows whether it already stands at a line start. - lastOut byte inFence bool - // inDiagram says the open fence is a diagram, the one block whose - // numbers are claims rather than code. - inDiagram bool - // docs says, per prompt source number - 1, whether that source is - // documentation, so a diagram node can be kept from citing one. Empty - // means nothing is: a renumberer that was never told what its sources - // are does not guess, and the tests that construct one with a count - // alone renumber exactly as they did before. - docs []bool // spell rewrites the prose between the markers and the code, set for a // German answer (speller.prose) and nil for every other language. With // it set, a word that reaches the end of what has arrived is held back @@ -61,12 +48,6 @@ func newRenumberer(sources int) *renumberer { return &renumberer{n: sources, dense: map[int]int{}} } -// isDoc reports whether the prompt's source n is documentation. Out of range -// is false: an invented number is not a document, it is not a source at all. -func (r *renumberer) isDoc(n int) bool { - return n >= 1 && n <= len(r.docs) && r.docs[n-1] -} - // A complete marker at the start of the text, or the start of one. The // prompt asks for [1][2], but a claim resting on several sources still comes // out as [1, 2] often enough; read as one marker it matched nothing. @@ -92,22 +73,13 @@ func (r *renumberer) feed(tok string) string { r.pending += tok out, rest := r.decide(r.pending, false) r.pending = rest - r.remember(out) return out } -// remember keeps the last byte emitted, the one openFence asks about. -func (r *renumberer) remember(out string) { - if out != "" { - r.lastOut = out[len(out)-1] - } -} - // flush ends the stream: whatever is still pending is decided as it stands. func (r *renumberer) flush() string { out, _ := r.decide(r.pending, true) r.pending = "" - r.remember(out) return out } @@ -119,37 +91,28 @@ func (r *renumberer) decide(s string, atEnd bool) (out string, rest string) { i := 0 for i < len(s) { if r.inFence { - // Nothing in a fence is a marker, with one exception: a diagram - // fence cites through the src arrays of its nodes, and those - // numbers are the same claim a marker in prose is, so they are - // renumbered with it (diagram.tsx draws them as the same chip). + // Nothing in a fence is a marker. A diagram fence included: its + // labels are drawn, not cited, and a number in one is a label. if end := strings.Index(s[i:], "```"); end >= 0 { - b.WriteString(r.fenceBody(s[i:i+end], true)) + b.WriteString(s[i : i+end]) b.WriteString("```") i += end + 3 r.inFence = false - r.inDiagram = false continue } if atEnd { - b.WriteString(r.fenceBody(s[i:], true)) + b.WriteString(s[i:]) return b.String(), "" } // Trailing backticks may be the start of the close. Counted over // what is left to decide, never over the whole buffer: the fence // that opened at i is made of backticks too. cut := len(s) - trailingBackticks(s[i:], 2) - if r.inDiagram { - out, rest := r.rewriteSrc(s[i:cut], false) - b.WriteString(out) - return b.String(), rest + s[cut:] - } b.WriteString(s[i:cut]) return b.String(), s[cut:] } - // The earliest of: a fence, an inline span, a marker, a spec that - // arrived without a fence at all. - j := strings.IndexAny(s[i:], "`[{") + // The earliest of: a fence, an inline span, a marker. + j := strings.IndexAny(s[i:], "`[") if j < 0 { if r.spell != nil && !atEnd { // The last word may go on in the next token. @@ -162,32 +125,6 @@ func (r *renumberer) decide(s string, atEnd bool) (out string, rest string) { } b.WriteString(r.prose(s[i : i+j])) i += j - if s[i] == '{' { - // A diagram the model wrote as bare JSON. Without this the - // object is prose, and the src arrays inside it are read as - // citation markers: the [6] of "src":[6] would be renumbered as - // though the sentence around it had cited source 6. - if jsonish(s[i:]) { - end := jsonEnd(s[i:]) - if end < 0 && !atEnd { - return b.String(), s[i:] // the object may still close - } - if end >= 0 && specKind(s[i:i+end]) != "" { - prev := r.lastOut - if b.Len() > 0 { - written := b.String() - prev = written[len(written)-1] - } - body, _ := r.rewriteSrc(s[i:i+end], true) - b.WriteString(openFence(prev) + body + "\n```\n") - i += end - continue - } - } - b.WriteByte('{') - i++ - continue - } if s[i] == '`' { // One or two backticks at the end of what has arrived may be the // start of an opening fence: held back, as the closing one is. @@ -198,23 +135,20 @@ func (r *renumberer) decide(s string, atEnd bool) (out string, rest string) { return b.String(), s[i:] } if strings.HasPrefix(s[i:], "```") { - // The info string says whether this is a diagram fence, whose - // src arrays renumber; held back until the line is whole, - // because the tag decides how the whole block is read. + // The header line is held back until it is whole: a second + // "```" on the same line closes it, and that is a span, not + // a block. Reading the rest of the line as an info string + // would leave the fence open over the whole answer - every + // marker after it silently uncited. nl := strings.IndexByte(s[i:], '\n') line := s[i:] if nl >= 0 { line = s[i : i+nl] } - // A second "```" on the same line closes it: that is a span, - // not a block, and reading the rest of the line as an info - // string would leave the fence open over the whole answer - - // every marker after it silently uncited. if strings.Contains(line[3:], "```") { b.WriteString("```") i += 3 r.inFence = true - r.inDiagram = false continue } if nl < 0 && !atEnd { @@ -224,37 +158,7 @@ func (r *renumberer) decide(s string, atEnd bool) (out string, rest string) { if nl >= 0 { raw = s[i : i+nl+1] } - head := raw - r.inDiagram = infoTag(raw) == "diagram" - if !r.inDiagram { - // The tag is not the one the prompt asked for, so the - // body decides. A spec opened as ```json is still the - // picture the answer meant; left as it came it renumbers - // nowhere and the reader is handed the JSON. The header - // is rewritten to the one both ends agree on. - // - // The body is read no further than this fence: a block - // the stream cut off mid-object must not have its brace - // matched against whatever the rest of the answer holds. - body := s[i+len(raw):] - if k := strings.Index(body, "```"); k >= 0 { - body = body[:k] - } - if jsonish(body) { - end := jsonEnd(body) - if end < 0 && !atEnd { - return b.String(), s[i:] - } - if end >= 0 && specKind(body[:end]) != "" { - r.inDiagram = true - head = "```diagram" - if strings.HasSuffix(raw, "\n") { - head += "\n" - } - } - } - } - b.WriteString(head) + b.WriteString(raw) i += len(raw) r.inFence = true continue @@ -296,81 +200,14 @@ func (r *renumberer) decide(s string, atEnd bool) (out string, rest string) { return b.String(), "" } -// A src array of a diagram node, or the start of one. Anchored on the KEY, -// never on the bracket: a node label may be `parts[2]`, and renumbering that -// would mint a citation out of an index expression - the fabrication the -// whole citation path exists to prevent. -// -// The value is read as a CHAIN of bracket groups, because answerCommon tells -// the model that two sources read [6][25], and it applies that inside the -// fence often enough. Written into JSON the chain is not parseable, so the -// browser shows the diagram as the code block it now is; worse, renumbering -// only the first group leaves the second at prompt numbering, which puts a -// wrong source under a chip. Both groups are read and the value is emitted -// as the one array it was meant to be. -// -// The groups may be separated by a comma as well as by nothing at all: the -// model reaching for JSON halfway writes [1],[43], which is neither the chain -// the prose rule asks for nor the array the fence needs, and is exactly as -// unparseable. It is the same value written a second way, so it folds the -// same way. A comma only joins two groups when a bracket follows it, so -// "src":[1],"kind" ends the value where it should. -var ( - // Whitespace is allowed everywhere JSON allows it: a model that writes - // "src" : [ 9 ] means the same array, and read strictly it went through - // unrenumbered - a prompt index drawn as a chip. - srcGroup = `\[\s*(?:\d{1,3}(?:\s*,\s*\d{1,3})*)\s*\]` - srcAtStart = regexp.MustCompile(`^"src"\s*:\s*(` + srcGroup + `(?:\s*,?\s*` + srcGroup + `)*)`) - srcPrefixRe = regexp.MustCompile(`^"(s(r(c("(\s*(:(\s*(\[[\d\s,]*)?)?)?)?)?)?)?)?$`) - // What may still grow into another group of the chain: nothing yet, a - // comma that has not been followed yet, or a bracket that has not closed. - // Held back until it is decided. - srcMoreRe = regexp.MustCompile(`^\s*(?:,\s*)?(\[[\d\s,]*)?$`) - // The seam between two groups of a chain, with or without the comma the - // model half-remembered, which becomes the one comma the array should - // have carried. - srcJoinRe = regexp.MustCompile(`\]\s*,?\s*\[`) -) - -// Reading the content rather than the fence tag is what keeps a diagram a -// diagram when the model opens the block as ```json, or writes it with no -// fence at all: three times now a picture has been thrown away because one -// exact match failed, and an exact match on the tag would have been the -// fourth. So nothing here matches a fixed opening. A candidate is anything -// shaped like a JSON object; it is read to its closing brace and then asked -// what it is, which is the one question that does not go stale when the -// model reorders its keys. - -// jsonish says s may be the start of a JSON object: a brace and then a key, -// whitespace ignored. It is deliberately weak - it only decides whether the -// text is worth holding until its closing brace arrives, and specKind makes -// the real decision - but weak is not nothing: prose does not open a brace -// and follow it with a quote, so an ordinary "{" in a sentence is passed -// through rather than stalling the stream. -func jsonish(s string) bool { - const want = `{"` - i := 0 - for _, r := range s { - if unicode.IsSpace(r) { - continue - } - if i == len(want) || byte(r) != want[i] { - return i == len(want) - } - i++ - } - return true // still a prefix of `{"`: it may yet become one -} - -// DiagramKind returns "flow" or "sequence" when the answer text carries a -// diagram the reader gets drawn, and "" when it carries none. It reads the -// text as the renumberer left it, so a spec the model fenced as json or wrote -// bare counts once retagged. What is measured is the picture, not the -// attempt, so the conditions are the browser's (ui/src/diagram.tsx, parse; -// markdown.tsx, fenceRe): a closed fence whose first header token is -// "diagram", a body that is valid JSON, and at least one node or actor with -// a string id and label - an empty or cut-off spec is shown as text there -// and counts as none here. The answers harness reports it per answer. +// DiagramKind returns the type of the diagram an answer carries, as the +// first word of its mermaid fence names it ("flowchart", "sequenceDiagram", +// "stateDiagram-v2", "erDiagram"), and "" when it carries none. It reads the +// text as the renumberer left it. What is measured is the fence, not the +// picture: whether the renderer draws it is the browser's call, and the UI +// corpus test (ui/src/corpus.test.ts) asks the renderer's parser exactly +// that for every answer in the corpus. The answers harness reports it per +// answer. func DiagramKind(text string) string { for rest := text; ; { i := strings.Index(rest, "```") @@ -386,8 +223,8 @@ func DiagramKind(text string) string { if end < 0 { return "" } - if infoTag(head) == "diagram" { - if kind := drawnKind(after[:end]); kind != "" { + if tag := infoTag(head); tag == "mermaid" || tag == "diagram" { + if kind := mermaidKind(after[:end]); kind != "" { return kind } } @@ -395,150 +232,33 @@ func DiagramKind(text string) string { } } -// drawnKind is the type of the spec body when the browser would draw it. -func drawnKind(body string) string { - var spec struct { - Type string `json:"type"` - Nodes []map[string]any `json:"nodes"` - Actors []map[string]any `json:"actors"` - } - if err := json.Unmarshal([]byte(body), &spec); err != nil { - return "" - } - drawn := func(items []map[string]any) bool { - for _, it := range items { - id, _ := it["id"].(string) - _, labelled := it["label"].(string) - if id != "" && labelled { - return true - } - } - return false - } - switch spec.Type { - case "flow": - if drawn(spec.Nodes) { - return "flow" - } - case "sequence": - if drawn(spec.Actors) { - return "sequence" +// mermaidKind is the first word of a mermaid source, past blank lines and +// %% comments or directives, the way the browser reads it (diagram.tsx, +// diagramKind). Empty when there is none, and empty when the word is not +// the shape of a type name: a fence holding the older JSON spec opens with +// a brace, and counting that as a diagram would put a picture in the +// harness's tally that the reader never got. +func mermaidKind(body string) string { + for _, line := range strings.Split(body, "\n") { + t := strings.TrimSpace(line) + if t == "" || strings.HasPrefix(t, "%%") { + continue } - } - return "" -} - -// specKind returns the top-level "type" of the JSON object o when it names a -// diagram this renderer draws, and "" otherwise. The key is read at depth one -// only, and read as a key rather than found anywhere in the text: rongo -// indexes rongo, so an answer that quotes answerDiagram's own format carries -// these very words inside a code block, and retagging that block would turn a -// developer's example into a picture. -func specKind(o string) string { - depth := 0 - for i := 0; i < len(o); { - switch o[i] { - case '{', '[': - depth++ - i++ - case '}', ']': - depth-- - i++ - case '"': - key, next := jsonString(o, i) - if depth != 1 || key != "type" { - i = next - continue - } - j := skipSpace(o, next) - if j >= len(o) || o[j] != ':' { - i = next - continue - } - if j = skipSpace(o, j+1); j >= len(o) || o[j] != '"' { - return "" - } - switch v, _ := jsonString(o, j); v { - case "flow", "sequence": - return v - } + word := strings.TrimRight(strings.Fields(t)[0], ";:") + if !kindWordRe.MatchString(word) { return "" - default: - i++ } + return word } return "" } -// jsonString reads the string literal starting at o[i] == '"' and returns its -// content and the index just past the closing quote. The content is returned -// raw: nothing here needs an unescaped "type". -func jsonString(o string, i int) (string, int) { - for j := i + 1; j < len(o); j++ { - switch o[j] { - case '\\': - j++ - case '"': - return o[i+1 : j], j + 1 - } - } - return "", len(o) -} - -func skipSpace(o string, i int) int { - for i < len(o) && (o[i] == ' ' || o[i] == '\t' || o[i] == '\n' || o[i] == '\r') { - i++ - } - return i -} - -// jsonEnd returns the index just past the object starting at s[0], or -1 -// when it has not arrived whole. A brace inside a string literal does not -// count, or a node labelled "{ }" would end the spec early. -func jsonEnd(s string) int { - depth, inStr, esc := 0, false, false - for i := 0; i < len(s); i++ { - c := s[i] - if inStr { - switch { - case esc: - esc = false - case c == '\\': - esc = true - case c == '"': - inStr = false - } - continue - } - switch c { - case '"': - inStr = true - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - return i + 1 - } - } - } - return -1 -} - -// openFence writes the header for a spec that arrived without one. A fence -// is read line by line (markdown.tsx fenceRe), so it needs a line of its own; -// prev is the byte it would follow, 0 at the very start of the answer. -func openFence(prev byte) string { - if prev == 0 || prev == '\n' { - return "```diagram\n" - } - return "\n```diagram\n" -} +// kindWordRe is the shape of a diagram type name: flowchart, graph, +// sequenceDiagram, stateDiagram-v2, erDiagram. +var kindWordRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9-]*$`) // infoTag reads the language of a fence header the way the browser does -// (markdown.tsx fenceRe): the first token of the info string. The two ends -// must agree on what a diagram fence is - one drawing a diagram the other -// left at prompt numbering would put a wrong source under a chip. +// (markdown.tsx fenceRe): the first token of the info string. func infoTag(head string) string { if f := strings.Fields(strings.Trim(head, "`\n")); len(f) > 0 { return f[0] @@ -546,65 +266,6 @@ func infoTag(head string) string { return "" } -// fenceBody hands a complete fence body to the renumberer for a diagram, and -// passes anything else through untouched. -func (r *renumberer) fenceBody(body string, atEnd bool) string { - if !r.inDiagram { - return body - } - out, rest := r.rewriteSrc(body, atEnd) - return out + rest -} - -// rewriteSrc renumbers the src arrays of a diagram fence, holding back a key -// or an array that has not arrived whole. -func (r *renumberer) rewriteSrc(s string, atEnd bool) (out string, rest string) { - var b strings.Builder - i := 0 - for i < len(s) { - j := strings.Index(s[i:], `"src"`) - if j < 0 { - // No key left, but the tail may be the start of one. - if !atEnd { - for k := len(s) - 1; k >= i && k > len(s)-6; k-- { - if srcPrefixRe.MatchString(s[k:]) { - b.WriteString(s[i:k]) - return b.String(), s[k:] - } - } - } - b.WriteString(s[i:]) - return b.String(), "" - } - b.WriteString(s[i : i+j]) - i += j - if m := srcAtStart.FindStringSubmatch(s[i:]); m != nil { - // A group that ends where the text does may be the first of a - // chain: decided only once what follows it has arrived. - if !atEnd && srcMoreRe.MatchString(s[i+len(m[0]):]) { - return b.String(), s[i:] - } - b.WriteString(`"src":` + r.rewriteArray(srcGroups(m[1]))) - i += len(m[0]) - continue - } - if !atEnd && srcPrefixRe.MatchString(s[i:]) { - return b.String(), s[i:] - } - b.WriteString(`"src"`) - i += len(`"src"`) - } - return b.String(), "" -} - -// srcGroups flattens a chain of bracket groups into the one group they meant, -// so [6][25] renumbers and is written back as the array [6,25]. What comes -// back out is rewriteArray's own array, sorted; only a group carrying an -// invented number keeps the separators it came in with. -func srcGroups(chain string) string { - return strings.TrimSuffix(strings.TrimPrefix(srcJoinRe.ReplaceAllString(chain, ","), "["), "]") -} - // rewrite renumbers the numbers of one marker group, keeping its separators. func (r *renumberer) rewrite(group string) string { return "[" + numberRe.ReplaceAllStringFunc(group, func(num string) string { @@ -676,58 +337,6 @@ func (r *renumberer) rewriteChain(chain string) string { return b.String() } -// dropDocs removes the markers of a node's src that rest on documentation. -// -// A node is a place in the mechanism and a document is not one, so a chip -// opening a README under a step of the control flow says the step was read -// there. No citation is better than that one: the node is still drawn, it -// just carries nothing to open. Prose is untouched — a claim resting on a -// document is made in a sentence, where answerCommon has the model say so. -// -// Filtered BEFORE markerRun assigns reader numbers, which is what keeps the -// rest of the answer's numbering intact: a source takes its number on first -// use, so a document dropped here still gets one where the prose cites it, -// and one cited nowhere else simply never becomes a citation. -// -// Everything that is not a known document survives, an invented number -// included: it is never a citation, and the UI drops it to plain text. -func (r *renumberer) dropDocs(group string) string { - if len(r.docs) == 0 { - return group - } - kept := make([]string, 0, 4) - for _, num := range numberRe.FindAllString(group, -1) { - if n, err := strconv.Atoi(num); err == nil && r.isDoc(n) { - continue - } - kept = append(kept, num) - } - return strings.Join(kept, ",") -} - -// rewriteArray writes a diagram node's src back sorted, as the one array the -// browser parses. Its chips are the chips of the prose, so they are ordered -// the same way. -// -// The documents go first, above the markerRun branch rather than inside it: -// a src mixing one with an invented number takes the fallback, and a filter -// living in the sorted path alone would leave that chip standing. -func (r *renumberer) rewriteArray(group string) string { - group = r.dropDocs(group) - if strings.TrimSpace(group) == "" { - return "[]" - } - dense, ok := r.markerRun(group) - if !ok { - return r.rewrite(group) - } - parts := make([]string, len(dense)) - for i, d := range dense { - parts[i] = strconv.Itoa(d) - } - return "[" + strings.Join(parts, ",") + "]" -} - // citations resolves the markers the answer used, in the reader's numbering. func (r *renumberer) citations(sources []Source) []Citation { out := make([]Citation, 0, len(r.order)) diff --git a/backend/internal/ask/renumber_corpus_test.go b/backend/internal/ask/renumber_corpus_test.go index f4d28bb4..340f0d5c 100644 --- a/backend/internal/ask/renumber_corpus_test.go +++ b/backend/internal/ask/renumber_corpus_test.go @@ -1,7 +1,6 @@ package ask import ( - "encoding/json" "flag" "os" "path/filepath" @@ -18,9 +17,12 @@ var update = flag.Bool("update", false, "rewrite the corpus golden files") // would have been a file here. // // Nothing is asserted about the wording of a fixture, only the invariants -// that make a diagram a diagram: it leaves the renumberer inside a ```diagram -// fence, every number in a src array is one of the reader's, and a stream -// broken into single bytes says exactly what one whole string says. +// that make a diagram a diagram: it leaves the renumberer inside one +// ```mermaid fence with its body byte for byte as it came, the prose around +// it renumbered, and a stream broken into single bytes says exactly what one +// whole string says. Whether the browser draws the body is the other half, +// asked of the renderer's own parser in ui/src/corpus.test.ts on the same +// files. // corpusSources is how many sources the fixtures may cite. Generous, so a // new file can use whatever markers the answer it came from used. @@ -50,7 +52,8 @@ func corpus(t *testing.T) map[string]string { return out } -// diagramFence returns the body of the one ```diagram fence in text, and +// diagramFence returns the body of the one diagram fence in text, read as +// the browser reads it (markdown.tsx: a `mermaid` or `diagram` tag), and // whether there is exactly one. func diagramFence(text string) (string, bool) { var body []string @@ -62,7 +65,8 @@ func diagramFence(text string) (string, bool) { continue } inside = true - isDiagram = infoTag(strings.TrimSpace(l)) == "diagram" + tag := infoTag(strings.TrimSpace(l)) + isDiagram = tag == "mermaid" || tag == "diagram" if isDiagram { found++ } @@ -83,34 +87,21 @@ func TestCorpus_everyShapeLeavesAsADiagramFence(t *testing.T) { body, ok := diagramFence(out) if !ok { - t.Fatalf("no single ```diagram fence in:\n%s", out) + t.Fatalf("no single diagram fence in:\n%s", out) } - - // The body has to be the JSON the browser parses. A src written - // as a chain of groups is not, which is the whole reason #45 - // existed. - var spec map[string]any - if err := json.Unmarshal([]byte(body), &spec); err != nil { - t.Fatalf("fence body is not JSON: %v\n%s", err, body) + // The fence is code to this end: what the model wrote is what + // the renderer gets, a marker-shaped label included. + if came, _ := diagramFence(text); came != body { + t.Errorf("fence body was rewritten\ngot:\n%s\nwant:\n%s", body, came) } - if spec["type"] != "flow" && spec["type"] != "sequence" { - t.Fatalf("type = %v, want flow or sequence", spec["type"]) - } - - // Every chip the picture will draw is one of the reader's - // numbers. A prompt index left behind here puts a wrong source - // under a node. - n := len(rn.citations(fakeSources(corpusSources))) - for _, m := range srcNumbers(t, spec) { - if m < 1 || m > n { - t.Errorf("src holds %d, outside the reader's 1..%d", m, n) - } + if DiagramKind(out) == "" { + t.Errorf("DiagramKind = %q, want the type the fence names:\n%s", "", body) } // The golden is what the browser is handed, and the UI reads the - // same files (ui/src/corpus.test.ts): a spec this end normalises - // and the other end will not draw is the same defect as one - // neither touches, and only a shared artefact catches it. + // same files (ui/src/corpus.test.ts): an answer this end passes + // and the other end will not draw is the defect only a shared + // artefact catches. golden := filepath.Join("testdata", "diagrams", strings.TrimSuffix(name, ".txt")+".golden") if *update { if err := os.WriteFile(golden, []byte(out), 0o644); err != nil { @@ -151,56 +142,25 @@ func TestCorpus_aStreamSaysWhatOneStringSays(t *testing.T) { } } -// The other half of reading a block by its content: what must NOT be read as -// a diagram. rongo indexes rongo, so a Developer answer explaining the format -// carries these very words, and a code block turned into a picture is the -// same defect as a picture turned into a code block. -func TestCorpus_codeThatOnlyLooksLikeASpecIsLeftAlone(t *testing.T) { +// What must NOT count as a diagram: a code block that happens to hold the +// words, a brace in prose, JSON in the answer. rongo indexes rongo, so a +// Developer answer explaining the format carries these very words. +func TestCorpus_codeThatOnlyLooksLikeADiagramIsLeftAlone(t *testing.T) { for name, text := range map[string]string{ - "a config with its own type": "Config [1].\n\n```json\n{\"pipeline\":{\"type\":\"flow\",\"steps\":2}}\n```\n", - "the format, quoted": "The prompt asks for [1]:\n\n```json\n{\"shape\":\"{\\\"type\\\":\\\"flow\\\"}\"}\n```\n", - "a type this file does not draw": "Not ours [1].\n\n```json\n{\"type\":\"pie\",\"slices\":[]}\n```\n", - "a brace in running prose": "The handler returns { on the empty path [1].\n", - "an object that is not a spec": "State [1].\n\n{\"repo\":\"rongo\",\"branch\":\"master\"}\n", + "a config with its own type": "Config [1].\n\n```json\n{\"pipeline\":{\"type\":\"flow\",\"steps\":2}}\n```\n", + "the syntax, quoted": "The prompt asks for [1]:\n\n```go\nconst head = \"flowchart TD\"\n```\n", + "a brace in running prose": "The handler returns { on the empty path [1].\n", + "an object that is not a spec": "State [1].\n\n{\"repo\":\"rongo\",\"branch\":\"master\"}\n", } { t.Run(name, func(t *testing.T) { rn := newRenumberer(corpusSources) out := rn.feed(text) + rn.flush() - if strings.Contains(out, "```diagram") { + if DiagramKind(out) != "" { t.Errorf("read as a diagram:\n%s", out) } - }) - } -} - -// srcNumbers collects every src entry of a spec. -func srcNumbers(t *testing.T, spec map[string]any) []int { - t.Helper() - var out []int - for _, key := range []string{"nodes", "steps"} { - items, _ := spec[key].([]any) - for _, it := range items { - rec, _ := it.(map[string]any) - arr, _ := rec["src"].([]any) - for _, v := range arr { - f, ok := v.(float64) - if !ok { - t.Errorf("src holds %#v, not a number", v) - continue - } - out = append(out, int(f)) + if !strings.Contains(out, "[1]") { + t.Errorf("the prose marker did not survive:\n%s", out) } - } - } - return out -} - -// fakeSources is a numbered list long enough for citations() to resolve -// anything the corpus cites. Only the count matters here. -func fakeSources(n int) []Source { - out := make([]Source, n) - for i := range out { - out[i] = Source{Repo: "repo", Branch: "master", Path: "a.go", StartLine: 1, EndLine: 2, SHA: "sha"} + }) } - return out } diff --git a/backend/internal/ask/swiss.go b/backend/internal/ask/swiss.go index edc51558..2b7b043d 100644 --- a/backend/internal/ask/swiss.go +++ b/backend/internal/ask/swiss.go @@ -27,8 +27,9 @@ import ( // camelCase, snake_case, a digit, a path or a dotted name keep the spelling // the source has, because `pruefeBetrag` renamed is a symbol that does not // exist. Fenced and inline code never reach here at all - a diagram fence's -// node labels included, which is the one place a reader sees German the -// model spelled: those are JSON strings, and this walks prose. +// labels included, which is the one place a reader sees German the model +// spelled: a label sits in diagram syntax, and this walks prose. A known +// gap, wider since every diagram type carries labels. // speller rewrites prose and remembers what it changed, so the eval can count // the words the model got wrong even though the reader never sees them. diff --git a/backend/internal/ask/testdata/diagrams/README.md b/backend/internal/ask/testdata/diagrams/README.md index 8f0dc030..3242c735 100644 --- a/backend/internal/ask/testdata/diagrams/README.md +++ b/backend/internal/ask/testdata/diagrams/README.md @@ -8,11 +8,12 @@ output the model produces, only the output the prompt asks for. So this is the corpus, and it is the process: a diagram that fails to draw gets its answer text dropped in here as a new file FIRST, and the fix afterwards. Both ends read the same files - `renumber_corpus_test.go` on the -backend, `diagram.corpus.test.ts` in the UI - because a spec the backend -renumbers and the browser will not draw is the same defect as one neither -touches. +backend, `corpus.test.ts` in the UI - because an answer the backend passes +and the browser will not draw is the same defect as one neither touches. A file is the raw answer text. Markers may name any source from 1 to 60; the -tests number that many. Every file must come out of the renumberer inside a -```diagram fence, with every src entry renumbered into 1..n, and must parse -into a spec the renderer draws. +tests number that many. Every file must come out of the renumberer with its +one `mermaid` (or `diagram`) fence byte for byte as it came and the prose +around it renumbered, and the fence body must pass the renderer's own parser +(`mermaid.parse`). Drawing it is a browser's job and is checked by hand in +the running app. diff --git a/backend/internal/ask/testdata/diagrams/code-then-diagram.golden b/backend/internal/ask/testdata/diagrams/code-then-diagram.golden new file mode 100644 index 00000000..3b84714d --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/code-then-diagram.golden @@ -0,0 +1,13 @@ +The handler reads the index expression [1]: + +```go +x := parts[1] +// "src":[2] +``` + +and the flow is [1][2]: + +```mermaid +flowchart TD + a["parse"] --> b["index"] +``` diff --git a/backend/internal/ask/testdata/diagrams/code-then-diagram.txt b/backend/internal/ask/testdata/diagrams/code-then-diagram.txt new file mode 100644 index 00000000..af45669b --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/code-then-diagram.txt @@ -0,0 +1,13 @@ +The handler reads the index expression [5]: + +```go +x := parts[1] +// "src":[2] +``` + +and the flow is [5][9]: + +```mermaid +flowchart TD + a["parse"] --> b["index"] +``` diff --git a/backend/internal/ask/testdata/diagrams/diagram-tag.golden b/backend/internal/ask/testdata/diagrams/diagram-tag.golden new file mode 100644 index 00000000..1156fcbd --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/diagram-tag.golden @@ -0,0 +1,9 @@ +The model opened the fence with the older tag [1]. + +```diagram +flowchart TD + a["Fetch"] --> b["Index"] + b --> c["Answer"] +``` + +Same picture [1]. diff --git a/backend/internal/ask/testdata/diagrams/diagram-tag.txt b/backend/internal/ask/testdata/diagrams/diagram-tag.txt new file mode 100644 index 00000000..4983c724 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/diagram-tag.txt @@ -0,0 +1,9 @@ +The model opened the fence with the older tag [3]. + +```diagram +flowchart TD + a["Fetch"] --> b["Index"] + b --> c["Answer"] +``` + +Same picture [3]. diff --git a/backend/internal/ask/testdata/diagrams/directive-first.golden b/backend/internal/ask/testdata/diagrams/directive-first.golden new file mode 100644 index 00000000..73e8b201 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/directive-first.golden @@ -0,0 +1,9 @@ +A directive before the type [1]. + +```mermaid +%%{init: {"flowchart": {"curve": "linear"}}}%% +flowchart LR + a["Request"] --> b["Handler"] +``` + +Drawn all the same [1]. diff --git a/backend/internal/ask/testdata/diagrams/directive-first.txt b/backend/internal/ask/testdata/diagrams/directive-first.txt new file mode 100644 index 00000000..c565289d --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/directive-first.txt @@ -0,0 +1,9 @@ +A directive before the type [2]. + +```mermaid +%%{init: {"flowchart": {"curve": "linear"}}}%% +flowchart LR + a["Request"] --> b["Handler"] +``` + +Drawn all the same [2]. diff --git a/backend/internal/ask/testdata/diagrams/er-model.golden b/backend/internal/ask/testdata/diagrams/er-model.golden new file mode 100644 index 00000000..4591d1be --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/er-model.golden @@ -0,0 +1,18 @@ +Three tables carry a thread [1][2]. + +```mermaid +erDiagram + THREAD ||--o{ MESSAGE : holds + MESSAGE ||--o{ CITATION : cites + THREAD ||--o| SHARE : "is shared as" + THREAD { + int id + string title + } + MESSAGE { + int ordinal + string answer + } +``` + +A share points at the thread, not at a message [2]. diff --git a/backend/internal/ask/testdata/diagrams/er-model.txt b/backend/internal/ask/testdata/diagrams/er-model.txt new file mode 100644 index 00000000..4f0d1101 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/er-model.txt @@ -0,0 +1,18 @@ +Three tables carry a thread [6][11]. + +```mermaid +erDiagram + THREAD ||--o{ MESSAGE : holds + MESSAGE ||--o{ CITATION : cites + THREAD ||--o| SHARE : "is shared as" + THREAD { + int id + string title + } + MESSAGE { + int ordinal + string answer + } +``` + +A share points at the thread, not at a message [11]. diff --git a/backend/internal/ask/testdata/diagrams/flowchart-decision.golden b/backend/internal/ask/testdata/diagrams/flowchart-decision.golden new file mode 100644 index 00000000..69fb41a3 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/flowchart-decision.golden @@ -0,0 +1,12 @@ +The answer path decides first whether anything was gathered [1]. + +```mermaid +flowchart TD + a(["Answer()"]) --> b{"sources empty?"} + b -->|"yes"| c(["NothingFound"]) + b -->|"no"| d["Stream to the model"] + d --> e["Renumber markers"] + e --> f(["Answer stored"]) +``` + +The stored text is what the reader watched being written [1][2]. diff --git a/backend/internal/ask/testdata/diagrams/flowchart-decision.txt b/backend/internal/ask/testdata/diagrams/flowchart-decision.txt new file mode 100644 index 00000000..82d6ddb8 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/flowchart-decision.txt @@ -0,0 +1,12 @@ +The answer path decides first whether anything was gathered [4]. + +```mermaid +flowchart TD + a(["Answer()"]) --> b{"sources empty?"} + b -->|"yes"| c(["NothingFound"]) + b -->|"no"| d["Stream to the model"] + d --> e["Renumber markers"] + e --> f(["Answer stored"]) +``` + +The stored text is what the reader watched being written [4][9]. diff --git a/backend/internal/ask/testdata/diagrams/json-fence.golden b/backend/internal/ask/testdata/diagrams/json-fence.golden deleted file mode 100644 index 97775381..00000000 --- a/backend/internal/ask/testdata/diagrams/json-fence.golden +++ /dev/null @@ -1,25 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -```diagram -{ - "type": "flow", - "nodes": [ - {"id": "new_thread", "label": "Neuer Thread wird erstellt", "kind": "start", "src":[2]}, - {"id": "process_msgs", "label": "Nachrichten verarbeiten und speichern", "kind": "step", "src":[3]}, - {"id": "enough_threads", "label": "Genug Threads vorhanden?", "kind": "decision", "src":[1]}, - {"id": "generate", "label": "Zusammenfassung generieren", "kind": "step", "src":[4]}, - {"id": "save_memory", "label": "Gedächtnis speichern", "kind": "step", "src":[5]}, - {"id": "memory_available", "label": "Projektgedächtnis bereit", "kind": "end", "src":[1]} - ], - "edges": [ - {"from": "new_thread", "to": "process_msgs", "label": "Nachrichten austauschen"}, - {"from": "process_msgs", "to": "enough_threads", "label": "Prüfung"}, - {"from": "enough_threads", "to": "new_thread", "label": "Nein"}, - {"from": "enough_threads", "to": "generate", "label": "Ja"}, - {"from": "generate", "to": "save_memory", "label": "Zusammenfassung erstellen"}, - {"from": "save_memory", "to": "memory_available", "label": "Bereitstellung"} - ] -} -``` - -Der Nutzer beginnt einen neuen Thread, in dem er Nachrichten austauscht [6]. diff --git a/backend/internal/ask/testdata/diagrams/json-fence.txt b/backend/internal/ask/testdata/diagrams/json-fence.txt deleted file mode 100644 index 2777c98a..00000000 --- a/backend/internal/ask/testdata/diagrams/json-fence.txt +++ /dev/null @@ -1,25 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -```json -{ - "type": "flow", - "nodes": [ - {"id": "new_thread", "label": "Neuer Thread wird erstellt", "kind": "start", "src": [6]}, - {"id": "process_msgs", "label": "Nachrichten verarbeiten und speichern", "kind": "step", "src": [23]}, - {"id": "enough_threads", "label": "Genug Threads vorhanden?", "kind": "decision", "src": [1]}, - {"id": "generate", "label": "Zusammenfassung generieren", "kind": "step", "src": [27]}, - {"id": "save_memory", "label": "Gedächtnis speichern", "kind": "step", "src": [54]}, - {"id": "memory_available", "label": "Projektgedächtnis bereit", "kind": "end", "src": [1]} - ], - "edges": [ - {"from": "new_thread", "to": "process_msgs", "label": "Nachrichten austauschen"}, - {"from": "process_msgs", "to": "enough_threads", "label": "Prüfung"}, - {"from": "enough_threads", "to": "new_thread", "label": "Nein"}, - {"from": "enough_threads", "to": "generate", "label": "Ja"}, - {"from": "generate", "to": "save_memory", "label": "Zusammenfassung erstellen"}, - {"from": "save_memory", "to": "memory_available", "label": "Bereitstellung"} - ] -} -``` - -Der Nutzer beginnt einen neuen Thread, in dem er Nachrichten austauscht [2]. diff --git a/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.golden b/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.golden deleted file mode 100644 index f930ed6a..00000000 --- a/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.golden +++ /dev/null @@ -1,8 +0,0 @@ -The same reordering, and no fence either [1]. - -```diagram -{"actors":[{"id":"ui","label":"UI"},{"id":"api","label":"Backend"}],"steps":[{"from":"ui","to":"api","label":"Frage stellen","kind":"call","src":[1]},{"from":"api","to":"ui","label":"Antwort streamen","kind":"return","src":[2]}],"type":"sequence"} -``` - - -Both ends have to agree that this is a diagram [2]. diff --git a/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.txt b/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.txt deleted file mode 100644 index d581f62b..00000000 --- a/backend/internal/ask/testdata/diagrams/keys-reordered-no-fence.txt +++ /dev/null @@ -1,5 +0,0 @@ -The same reordering, and no fence either [5]. - -{"actors":[{"id":"ui","label":"UI"},{"id":"api","label":"Backend"}],"steps":[{"from":"ui","to":"api","label":"Frage stellen","kind":"call","src":[5]},{"from":"api","to":"ui","label":"Antwort streamen","kind":"return","src":[8]}],"type":"sequence"} - -Both ends have to agree that this is a diagram [8]. diff --git a/backend/internal/ask/testdata/diagrams/keys-reordered.golden b/backend/internal/ask/testdata/diagrams/keys-reordered.golden deleted file mode 100644 index 5262d27e..00000000 --- a/backend/internal/ask/testdata/diagrams/keys-reordered.golden +++ /dev/null @@ -1,7 +0,0 @@ -The type is not the first key, which no prefix match would have caught [1]. - -```diagram -{"nodes":[{"id":"a","label":"Read config","kind":"start","src":[1]},{"id":"b","label":"Start pipeline","kind":"end","src":[2]}],"edges":[{"from":"a","to":"b","label":"Ready"}],"type":"flow"} -``` - -The pipeline then runs [2]. diff --git a/backend/internal/ask/testdata/diagrams/keys-reordered.txt b/backend/internal/ask/testdata/diagrams/keys-reordered.txt deleted file mode 100644 index c758081c..00000000 --- a/backend/internal/ask/testdata/diagrams/keys-reordered.txt +++ /dev/null @@ -1,7 +0,0 @@ -The type is not the first key, which no prefix match would have caught [5]. - -```json -{"nodes":[{"id":"a","label":"Read config","kind":"start","src":[5]},{"id":"b","label":"Start pipeline","kind":"end","src":[8]}],"edges":[{"from":"a","to":"b","label":"Ready"}],"type":"flow"} -``` - -The pipeline then runs [8]. diff --git a/backend/internal/ask/testdata/diagrams/labels-with-brackets.golden b/backend/internal/ask/testdata/diagrams/labels-with-brackets.golden new file mode 100644 index 00000000..dd1918b6 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/labels-with-brackets.golden @@ -0,0 +1,9 @@ +A label may hold anything, a marker included [1][2]. + +```mermaid +flowchart TD + a["parts[2]"] --> b["Step [1]: gather"] + b --> c["done (ok)"] +``` + +None of it is a claim; the claims are here [1]. diff --git a/backend/internal/ask/testdata/diagrams/labels-with-brackets.txt b/backend/internal/ask/testdata/diagrams/labels-with-brackets.txt new file mode 100644 index 00000000..e95e15b0 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/labels-with-brackets.txt @@ -0,0 +1,9 @@ +A label may hold anything, a marker included [7][2]. + +```mermaid +flowchart TD + a["parts[2]"] --> b["Step [1]: gather"] + b --> c["done (ok)"] +``` + +None of it is a claim; the claims are here [7]. diff --git a/backend/internal/ask/testdata/diagrams/mapping-lr.golden b/backend/internal/ask/testdata/diagrams/mapping-lr.golden new file mode 100644 index 00000000..53af60d8 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/mapping-lr.golden @@ -0,0 +1,25 @@ +Die Schadenmeldung-Anwendung produziert in fünf Kafka-Topics, jeweils ausgelöst durch ein Geschäftsereignis [1][2]. + +Das Diagramm zeigt, welches Ereignis welches Topic bedient [2][3][4]. + +```mermaid +flowchart LR + subgraph Ereignis + e1["Schadenmeldung eingegangen"] + e2["Nachreichung eingegangen"] + e3["Vorerfassung erstellt"] + end + subgraph Topic + t1["Benachrichtigung"] + t2["Arbeitsunfähigkeit"] + t3["Vorerfassung"] + t4["Digitale ID"] + end + e1 --> t1 + e1 -->|"ID-Prüfung nötig"| t4 + e2 --> t1 + e2 -->|"nur mit Zeiträumen"| t2 + e3 --> t3 +``` + +Die Zuordnung ist im Code fest verdrahtet [1]. diff --git a/backend/internal/ask/testdata/diagrams/mapping-lr.txt b/backend/internal/ask/testdata/diagrams/mapping-lr.txt new file mode 100644 index 00000000..8b9bf3b0 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/mapping-lr.txt @@ -0,0 +1,25 @@ +Die Schadenmeldung-Anwendung produziert in fünf Kafka-Topics, jeweils ausgelöst durch ein Geschäftsereignis [12][3]. + +Das Diagramm zeigt, welches Ereignis welches Topic bedient [3][7][15]. + +```mermaid +flowchart LR + subgraph Ereignis + e1["Schadenmeldung eingegangen"] + e2["Nachreichung eingegangen"] + e3["Vorerfassung erstellt"] + end + subgraph Topic + t1["Benachrichtigung"] + t2["Arbeitsunfähigkeit"] + t3["Vorerfassung"] + t4["Digitale ID"] + end + e1 --> t1 + e1 -->|"ID-Prüfung nötig"| t4 + e2 --> t1 + e2 -->|"nur mit Zeiträumen"| t2 + e3 --> t3 +``` + +Die Zuordnung ist im Code fest verdrahtet [12]. diff --git a/backend/internal/ask/testdata/diagrams/mermaid-tag.golden b/backend/internal/ask/testdata/diagrams/mermaid-tag.golden deleted file mode 100644 index 805fed0a..00000000 --- a/backend/internal/ask/testdata/diagrams/mermaid-tag.golden +++ /dev/null @@ -1,7 +0,0 @@ -The sequence below follows one indexing pass [1]. - -```diagram -{"type":"sequence","actors":[{"id":"idx","label":"Indexer"},{"id":"db","label":"SQLite"}],"steps":[{"from":"idx","to":"db","label":"Store chunks","kind":"call","src":[2]},{"from":"db","to":"idx","label":"Rows written","kind":"return","src":[2,3]}]} -``` - -The pass ends once the rows are written [1]. diff --git a/backend/internal/ask/testdata/diagrams/mermaid-tag.txt b/backend/internal/ask/testdata/diagrams/mermaid-tag.txt deleted file mode 100644 index 1626df6d..00000000 --- a/backend/internal/ask/testdata/diagrams/mermaid-tag.txt +++ /dev/null @@ -1,7 +0,0 @@ -The sequence below follows one indexing pass [3]. - -```mermaid -{"type":"sequence","actors":[{"id":"idx","label":"Indexer"},{"id":"db","label":"SQLite"}],"steps":[{"from":"idx","to":"db","label":"Store chunks","kind":"call","src":[7]},{"from":"db","to":"idx","label":"Rows written","kind":"return","src":[7,12]}]} -``` - -The pass ends once the rows are written [3]. diff --git a/backend/internal/ask/testdata/diagrams/no-fence.golden b/backend/internal/ask/testdata/diagrams/no-fence.golden deleted file mode 100644 index a0bdc513..00000000 --- a/backend/internal/ask/testdata/diagrams/no-fence.golden +++ /dev/null @@ -1,21 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -```diagram -{ - "type": "flow", - "nodes": [ - {"id": "new_thread", "label": "Neuer Thread wird erstellt", "kind": "start", "src":[2]}, - {"id": "process_msgs", "label": "Nachrichten verarbeiten", "kind": "step", "src":[3]}, - {"id": "enough_threads", "label": "Genug Threads vorhanden?", "kind": "decision", "src":[1]}, - {"id": "memory_available", "label": "Projektgedächtnis bereit", "kind": "end", "src":[4]} - ], - "edges": [ - {"from": "new_thread", "to": "process_msgs", "label": "Nachrichten austauschen"}, - {"from": "process_msgs", "to": "enough_threads", "label": "Prüfung"}, - {"from": "enough_threads", "to": "memory_available", "label": "Ja"} - ] -} -``` - - -Der Nutzer beginnt einen neuen Thread, in dem er Nachrichten austauscht [5]. diff --git a/backend/internal/ask/testdata/diagrams/no-fence.txt b/backend/internal/ask/testdata/diagrams/no-fence.txt deleted file mode 100644 index 41c74434..00000000 --- a/backend/internal/ask/testdata/diagrams/no-fence.txt +++ /dev/null @@ -1,18 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -{ - "type": "flow", - "nodes": [ - {"id": "new_thread", "label": "Neuer Thread wird erstellt", "kind": "start", "src": [6]}, - {"id": "process_msgs", "label": "Nachrichten verarbeiten", "kind": "step", "src": [23]}, - {"id": "enough_threads", "label": "Genug Threads vorhanden?", "kind": "decision", "src": [1]}, - {"id": "memory_available", "label": "Projektgedächtnis bereit", "kind": "end", "src": [54]} - ], - "edges": [ - {"from": "new_thread", "to": "process_msgs", "label": "Nachrichten austauschen"}, - {"from": "process_msgs", "to": "enough_threads", "label": "Prüfung"}, - {"from": "enough_threads", "to": "memory_available", "label": "Ja"} - ] -} - -Der Nutzer beginnt einen neuen Thread, in dem er Nachrichten austauscht [2]. diff --git a/backend/internal/ask/testdata/diagrams/sequence-de.golden b/backend/internal/ask/testdata/diagrams/sequence-de.golden new file mode 100644 index 00000000..22351147 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/sequence-de.golden @@ -0,0 +1,14 @@ +Der Aufruf läuft über drei Stationen [1][2]. + +```mermaid +sequenceDiagram + participant UI as Ask.tsx + participant API as httpapi + participant A as Answerer + UI->>API: POST /api/ask + API->>A: Answer() + A-->>UI: Token-Stream + A-)API: Zitate (async) +``` + +Die Zitate kommen zuletzt [2]. diff --git a/backend/internal/ask/testdata/diagrams/sequence-de.txt b/backend/internal/ask/testdata/diagrams/sequence-de.txt new file mode 100644 index 00000000..b9f1c21c --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/sequence-de.txt @@ -0,0 +1,14 @@ +Der Aufruf läuft über drei Stationen [2][5]. + +```mermaid +sequenceDiagram + participant UI as Ask.tsx + participant API as httpapi + participant A as Answerer + UI->>API: POST /api/ask + API->>A: Answer() + A-->>UI: Token-Stream + A-)API: Zitate (async) +``` + +Die Zitate kommen zuletzt [5]. diff --git a/backend/internal/ask/testdata/diagrams/spaced-open.golden b/backend/internal/ask/testdata/diagrams/spaced-open.golden index 91646632..fdf12bc6 100644 --- a/backend/internal/ask/testdata/diagrams/spaced-open.golden +++ b/backend/internal/ask/testdata/diagrams/spaced-open.golden @@ -1,14 +1,8 @@ -A spec written with room around the keys, and no fence to hold it [1]. +Ein Leerzeichen vor dem Tag [1]. -```diagram -{ "type" : "flow" , - "nodes" : [ - { "id" : "a" , "label" : "Frage stellen" , "kind" : "start" , "src":[1] } , - { "id" : "b" , "label" : "Antwort mit { } im Label" , "kind" : "end" , "src":[1,2] } - ] , - "edges" : [ { "from" : "a" , "to" : "b" , "label" : "Routing" } ] -} +``` mermaid +sequenceDiagram + A->>B: hallo ``` - -The braces inside the label must not end the object early [1]. +Bleibt ein Diagramm [1]. diff --git a/backend/internal/ask/testdata/diagrams/spaced-open.txt b/backend/internal/ask/testdata/diagrams/spaced-open.txt index 37134ec6..93421fe1 100644 --- a/backend/internal/ask/testdata/diagrams/spaced-open.txt +++ b/backend/internal/ask/testdata/diagrams/spaced-open.txt @@ -1,11 +1,8 @@ -A spec written with room around the keys, and no fence to hold it [9]. +Ein Leerzeichen vor dem Tag [4]. -{ "type" : "flow" , - "nodes" : [ - { "id" : "a" , "label" : "Frage stellen" , "kind" : "start" , "src" : [ 9 ] } , - { "id" : "b" , "label" : "Antwort mit { } im Label" , "kind" : "end" , "src" : [ 2 , 9 ] } - ] , - "edges" : [ { "from" : "a" , "to" : "b" , "label" : "Routing" } ] -} +``` mermaid +sequenceDiagram + A->>B: hallo +``` -The braces inside the label must not end the object early [9]. +Bleibt ein Diagramm [4]. diff --git a/backend/internal/ask/testdata/diagrams/src-as-chain.golden b/backend/internal/ask/testdata/diagrams/src-as-chain.golden deleted file mode 100644 index 91acc08f..00000000 --- a/backend/internal/ask/testdata/diagrams/src-as-chain.golden +++ /dev/null @@ -1,8 +0,0 @@ -Two sources carry the first step, and the model wrote them the way the prose -rule asks for rather than as the array the fence needs (#45) [1]. - -```diagram -{"type":"flow","nodes":[{"id":"a","label":"Read config","kind":"start","src":[2,3]},{"id":"b","label":"Start pipeline","kind":"end","src":[4]}],"edges":[{"from":"a","to":"b"}]} -``` - -The pipeline then runs [1]. diff --git a/backend/internal/ask/testdata/diagrams/src-as-chain.txt b/backend/internal/ask/testdata/diagrams/src-as-chain.txt deleted file mode 100644 index 0ca476e9..00000000 --- a/backend/internal/ask/testdata/diagrams/src-as-chain.txt +++ /dev/null @@ -1,8 +0,0 @@ -Two sources carry the first step, and the model wrote them the way the prose -rule asks for rather than as the array the fence needs (#45) [4]. - -```diagram -{"type":"flow","nodes":[{"id":"a","label":"Read config","kind":"start","src":[6][25]},{"id":"b","label":"Start pipeline","kind":"end","src":[11]}],"edges":[{"from":"a","to":"b"}]} -``` - -The pipeline then runs [4]. diff --git a/backend/internal/ask/testdata/diagrams/src-as-comma-chain.golden b/backend/internal/ask/testdata/diagrams/src-as-comma-chain.golden deleted file mode 100644 index 50fa50cd..00000000 --- a/backend/internal/ask/testdata/diagrams/src-as-comma-chain.golden +++ /dev/null @@ -1,8 +0,0 @@ -Die Lohnangaben-Validierung laeuft in zwei Ebenen, und der Chain aus #45 kam -diesmal mit Komma zwischen den Gruppen zurueck [1]. - -```diagram -{"type":"flow","nodes":[{"id":"start","label":"Start: Lohnangaben-Validierung","kind":"start","src":[1]},{"id":"bagCheck","label":"Bagatellunfall?","kind":"decision","src":[1,2]},{"id":"bagReject","label":"Fehler: Lohnangaben nicht erlaubt bei Bagatellunfall","kind":"step","src":[1]},{"id":"extract","label":"Lohnbestandteile aus Meldung extrahieren: Grundlohn, Gratifikation, Kinderzulagen, Übrige, Jahreslohn","kind":"step","src":[1]},{"id":"meldCheck","label":"MeldungOhneLohnangaben?","kind":"decision","src":[3,4]},{"id":"noWageVal","label":"Prüfen: Kein Betrag bei Gratifikation, Kinderzulagen, Übrige; Jahreslohn muss null sein","kind":"step","src":[3,4]},{"id":"fieldVal","label":"Einzelne Felder gegen Syrius prüfen (siehe Ebene 2)","kind":"step","src":[5]},{"id":"ferien","label":"Ferienentschädigung prüfen: darf nicht gesetzt sein","kind":"step","src":[3]},{"id":"done","label":"Validierung abgeschlossen","kind":"end","src":[3]},{"id":"n1","label":"E: Feld fehlt in Syrius, ist aber in Meldung vorhanden","kind":"step","src":[5]},{"id":"n2","label":"E: Feld in Meldung fehlt, Syrius hat Wert","kind":"step","src":[5]},{"id":"n3","label":"E: Währungscode nicht CHF","kind":"step","src":[5]}],"edges":[{"from":"start","to":"bagCheck"},{"from":"bagCheck","label":"Ja","to":"bagReject"},{"from":"bagCheck","label":"Nein","to":"extract"},{"from":"extract","to":"meldCheck"},{"from":"meldCheck","label":"Ja","to":"noWageVal"},{"from":"meldCheck","label":"Nein","to":"fieldVal"},{"from":"fieldVal","to":"ferien"},{"from":"ferien","to":"done"},{"from":"n1","to":"done","label":"Fehler"},{"from":"n2","to":"done","label":"Fehler"},{"from":"n3","to":"done","label":"Fehler"}]} -``` - -Ebene 2 prueft die einzelnen Felder gegen Syrius [2]. diff --git a/backend/internal/ask/testdata/diagrams/src-as-comma-chain.txt b/backend/internal/ask/testdata/diagrams/src-as-comma-chain.txt deleted file mode 100644 index 6a9eaac7..00000000 --- a/backend/internal/ask/testdata/diagrams/src-as-comma-chain.txt +++ /dev/null @@ -1,8 +0,0 @@ -Die Lohnangaben-Validierung laeuft in zwei Ebenen, und der Chain aus #45 kam -diesmal mit Komma zwischen den Gruppen zurueck [1]. - -```diagram -{"type":"flow","nodes":[{"id":"start","label":"Start: Lohnangaben-Validierung","kind":"start","src":[1]},{"id":"bagCheck","label":"Bagatellunfall?","kind":"decision","src":[1],[43]},{"id":"bagReject","label":"Fehler: Lohnangaben nicht erlaubt bei Bagatellunfall","kind":"step","src":[1]},{"id":"extract","label":"Lohnbestandteile aus Meldung extrahieren: Grundlohn, Gratifikation, Kinderzulagen, Übrige, Jahreslohn","kind":"step","src":[1]},{"id":"meldCheck","label":"MeldungOhneLohnangaben?","kind":"decision","src":[2],[12]},{"id":"noWageVal","label":"Prüfen: Kein Betrag bei Gratifikation, Kinderzulagen, Übrige; Jahreslohn muss null sein","kind":"step","src":[2],[12]},{"id":"fieldVal","label":"Einzelne Felder gegen Syrius prüfen (siehe Ebene 2)","kind":"step","src":[3]},{"id":"ferien","label":"Ferienentschädigung prüfen: darf nicht gesetzt sein","kind":"step","src":[2]},{"id":"done","label":"Validierung abgeschlossen","kind":"end","src":[2]},{"id":"n1","label":"E: Feld fehlt in Syrius, ist aber in Meldung vorhanden","kind":"step","src":[3]},{"id":"n2","label":"E: Feld in Meldung fehlt, Syrius hat Wert","kind":"step","src":[3]},{"id":"n3","label":"E: Währungscode nicht CHF","kind":"step","src":[3]}],"edges":[{"from":"start","to":"bagCheck"},{"from":"bagCheck","label":"Ja","to":"bagReject"},{"from":"bagCheck","label":"Nein","to":"extract"},{"from":"extract","to":"meldCheck"},{"from":"meldCheck","label":"Ja","to":"noWageVal"},{"from":"meldCheck","label":"Nein","to":"fieldVal"},{"from":"fieldVal","to":"ferien"},{"from":"ferien","to":"done"},{"from":"n1","to":"done","label":"Fehler"},{"from":"n2","to":"done","label":"Fehler"},{"from":"n3","to":"done","label":"Fehler"}]} -``` - -Ebene 2 prueft die einzelnen Felder gegen Syrius [43]. diff --git a/backend/internal/ask/testdata/diagrams/state-machine.golden b/backend/internal/ask/testdata/diagrams/state-machine.golden new file mode 100644 index 00000000..b72b80fe --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/state-machine.golden @@ -0,0 +1,13 @@ +A thread moves through four states, and only the owner moves it [1][2]. + +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> Shared : share() + Shared --> Draft : unshare() + Shared --> Archived : archive() + Draft --> Archived : archive() + Archived --> [*] +``` + +An archived thread is never shared again [1]. diff --git a/backend/internal/ask/testdata/diagrams/state-machine.txt b/backend/internal/ask/testdata/diagrams/state-machine.txt new file mode 100644 index 00000000..80135a55 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/state-machine.txt @@ -0,0 +1,13 @@ +A thread moves through four states, and only the owner moves it [8][1]. + +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> Shared : share() + Shared --> Draft : unshare() + Shared --> Archived : archive() + Draft --> Archived : archive() + Archived --> [*] +``` + +An archived thread is never shared again [8]. diff --git a/backend/internal/ask/testdata/diagrams/tagged-diagram.golden b/backend/internal/ask/testdata/diagrams/tagged-diagram.golden deleted file mode 100644 index 398741f1..00000000 --- a/backend/internal/ask/testdata/diagrams/tagged-diagram.golden +++ /dev/null @@ -1,7 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -```diagram -{"type":"flow","nodes":[{"id":"new_thread","label":"Neuer Thread","kind":"start","src":[2]},{"id":"save","label":"Gedächtnis speichern","kind":"step","src":[3]},{"id":"ready","label":"Gedächtnis bereit","kind":"end","src":[1]}],"edges":[{"from":"new_thread","to":"save","label":"Prüfung"},{"from":"save","to":"ready"}]} -``` - -Der Nutzer beginnt einen neuen Thread [4]. diff --git a/backend/internal/ask/testdata/diagrams/tagged-diagram.txt b/backend/internal/ask/testdata/diagrams/tagged-diagram.txt deleted file mode 100644 index 61a857e4..00000000 --- a/backend/internal/ask/testdata/diagrams/tagged-diagram.txt +++ /dev/null @@ -1,7 +0,0 @@ -Das Diagramm zeigt den Fluss, wie das Projektgedächtnis entsteht [1]. - -```diagram -{"type":"flow","nodes":[{"id":"new_thread","label":"Neuer Thread","kind":"start","src":[6]},{"id":"save","label":"Gedächtnis speichern","kind":"step","src":[54]},{"id":"ready","label":"Gedächtnis bereit","kind":"end","src":[1]}],"edges":[{"from":"new_thread","to":"save","label":"Prüfung"},{"from":"save","to":"ready"}]} -``` - -Der Nutzer beginnt einen neuen Thread [2]. diff --git a/backend/internal/ask/testdata/diagrams/wide-sequence.golden b/backend/internal/ask/testdata/diagrams/wide-sequence.golden deleted file mode 100644 index 34c8941c..00000000 --- a/backend/internal/ask/testdata/diagrams/wide-sequence.golden +++ /dev/null @@ -1,8 +0,0 @@ -Eight actors, past the five the prompt asks for, and drawable in every other -respect (#60) [1]. - -```diagram -{"type":"sequence","actors":[{"id":"cfg","label":"repos.yaml"},{"id":"startup","label":"Startup"},{"id":"indexer","label":"Indexer"},{"id":"git","label":"git"},{"id":"ctags","label":"universal-ctags"},{"id":"rg","label":"ripgrep"},{"id":"embed","label":"Embedding Service"},{"id":"db","label":"SQLite (FTS5 + vec)"}],"steps":[{"from":"cfg","to":"startup","label":"Load repository list","kind":"call","src":[1,2]},{"from":"startup","to":"indexer","label":"Sync specs and start pipeline","kind":"call","src":[2,3]},{"from":"indexer","to":"git","label":"Clone or fetch repository","kind":"call","src":[4,5]},{"from":"indexer","to":"ctags","label":"Extract symbols","kind":"call","src":[6,7]},{"from":"indexer","to":"rg","label":"Keyword indexing","kind":"call","src":[4,7]},{"from":"indexer","to":"embed","label":"Embed chunk text","kind":"call","src":[4,8]},{"from":"indexer","to":"db","label":"Store files, symbols, chunks","kind":"return","src":[7,9]}]} -``` - -Every step is attributed [1]. diff --git a/backend/internal/ask/testdata/diagrams/wide-sequence.txt b/backend/internal/ask/testdata/diagrams/wide-sequence.txt deleted file mode 100644 index a2800c03..00000000 --- a/backend/internal/ask/testdata/diagrams/wide-sequence.txt +++ /dev/null @@ -1,8 +0,0 @@ -Eight actors, past the five the prompt asks for, and drawable in every other -respect (#60) [1]. - -```diagram -{"type":"sequence","actors":[{"id":"cfg","label":"repos.yaml"},{"id":"startup","label":"Startup"},{"id":"indexer","label":"Indexer"},{"id":"git","label":"git"},{"id":"ctags","label":"universal-ctags"},{"id":"rg","label":"ripgrep"},{"id":"embed","label":"Embedding Service"},{"id":"db","label":"SQLite (FTS5 + vec)"}],"steps":[{"from":"cfg","to":"startup","label":"Load repository list","kind":"call","src":[1,2]},{"from":"startup","to":"indexer","label":"Sync specs and start pipeline","kind":"call","src":[2,11]},{"from":"indexer","to":"git","label":"Clone or fetch repository","kind":"call","src":[3,4]},{"from":"indexer","to":"ctags","label":"Extract symbols","kind":"call","src":[6,7]},{"from":"indexer","to":"rg","label":"Keyword indexing","kind":"call","src":[3,7]},{"from":"indexer","to":"embed","label":"Embed chunk text","kind":"call","src":[3,8]},{"from":"indexer","to":"db","label":"Store files, symbols, chunks","kind":"return","src":[5,7]}]} -``` - -Every step is attributed [1]. diff --git a/backend/internal/retrieve/eval/answers_test.go b/backend/internal/retrieve/eval/answers_test.go index ebd0e44e..09e3e09f 100644 --- a/backend/internal/retrieve/eval/answers_test.go +++ b/backend/internal/retrieve/eval/answers_test.go @@ -221,8 +221,9 @@ type answerRecord struct { // The reader never sees them - swiss.go corrects the stream - so this // column is the only place the deployment's German spelling shows. Digraphs []string `json:"digraphs,omitempty"` - // Diagram is "flow" or "sequence" when the answer draws one - // (ask.DiagramKind), "" when the reader gets prose alone. + // Diagram is the type the answer's diagram fence names, "flowchart", + // "sequenceDiagram", "stateDiagram-v2", "erDiagram" (ask.DiagramKind), + // and "" when the reader gets prose alone. Diagram string `json:"diagram,omitempty"` Err string `json:"err,omitempty"` } diff --git a/ui/package-lock.json b/ui/package-lock.json index 3710ea85..3a850af4 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "highlight.js": "~11.12.0", "lowlight": "^3.3.0", + "mermaid": "^12.0.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, @@ -30,6 +31,28 @@ "vitest": "^5.0.0" } }, + "node_modules/@antfu/install-pkg": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-2.1.0.tgz", + "integrity": "sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.8.0", + "tinyexec": "^1.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", @@ -149,6 +172,12 @@ "node": ">=18" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -162,6 +191,45 @@ "specificity": "bin/cli.js" } }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/gast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/@chevrotain/regexp-to-ast": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", + "license": "Apache-2.0" + }, "node_modules/@csstools/color-helpers": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", @@ -320,6 +388,23 @@ } } }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.7.tgz", + "integrity": "sha512-JZHlwdID+dy+lTgbYC8NEC4zeugqeYsc6jewvzb4c58kHauJn+X7rNwQjxz5p2qSjqaEeQoLkCIQ9v/H4PK0/w==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^2.0.1", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -370,6 +455,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mermaid-js/parser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-2.0.0.tgz", + "integrity": "sha512-K8BeapFUfrfxbRAUQAG5oBOCwo1+bNWzaVnPxTCutkfrTBn6T/j91FIhqxWJ32SUeQ9T3iy1zcmPZ5ROZEvDrg==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -1045,6 +1142,259 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1059,6 +1409,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", @@ -1088,6 +1444,13 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1434,6 +1797,16 @@ "node": ">=16.20.0" } }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -1645,6 +2018,38 @@ "node": ">=18" } }, + "node_modules/chevrotain": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", + "lodash-es": "4.17.23" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -1666,6 +2071,505 @@ "dev": true, "license": "MIT" }, + "node_modules/cytoscape": { + "version": "3.34.3", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.3.tgz", + "integrity": "sha512-yfYGhRcGAntq6YBD583j4n0Eg3jIxvWmZtz/5uz9UYkeIStSlMxuUja+ec5j3iBD8nv1rwaOAYMW09tBdkSeaQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -1680,6 +2584,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -1687,6 +2597,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1727,6 +2646,21 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==", + "license": "EPL-2.0" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -1761,6 +2695,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1821,6 +2767,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/highlight.js": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", @@ -1843,6 +2795,37 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -1924,6 +2907,42 @@ "node": "^22.14.0 || >=24.0.0" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2185,6 +3204,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -2252,6 +3277,18 @@ "source-map-js": "^1.2.1" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -2259,6 +3296,40 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mermaid": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-12.0.0.tgz", + "integrity": "sha512-/wQXC9iBxoGV8p3erbvaXs9h77VyLDBH6GdayVjj3hEcSQhFU4N1WUhUppotCEqlIxI2pRMwjwBSwTB1MfZBgQ==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^2.0.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "chevrotain": "~11.1.2", + "cytoscape": "^3.34.0", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.21", + "dompurify": "^3.4.12", + "elkjs": "^0.9.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.47", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -2292,6 +3363,12 @@ "node": ">=12.20.0" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2305,6 +3382,12 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2325,6 +3408,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -2419,6 +3518,12 @@ "node": ">=0.10.0" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, "node_modules/rolldown": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", @@ -2452,6 +3557,30 @@ "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2502,6 +3631,12 @@ "dev": true, "license": "MIT" }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2623,6 +3758,15 @@ "node": ">=20" } }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2668,6 +3812,19 @@ "node": ">=22.19.0" } }, + "node_modules/uuid": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", diff --git a/ui/package.json b/ui/package.json index 2335c829..8c172633 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "dependencies": { "highlight.js": "~11.12.0", "lowlight": "^3.3.0", + "mermaid": "^12.0.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/ui/src/DiagramView.test.tsx b/ui/src/DiagramView.test.tsx index 1e56a5f2..8e896c90 100644 --- a/ui/src/DiagramView.test.tsx +++ b/ui/src/DiagramView.test.tsx @@ -1,50 +1,29 @@ -import { describe, it, expect, vi, beforeAll } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, fireEvent, createEvent } from "@testing-library/react"; import DiagramView from "./DiagramView"; -import { diagramSize, type SequenceSpec } from "./diagram"; -const seq: SequenceSpec = { - type: "sequence", - actors: [ - { id: "ui", label: "Ask.tsx" }, - { id: "api", label: "httpapi" }, - { id: "ask", label: "Answerer" }, - ], - steps: [ - { from: "ui", to: "api", label: "POST /api/ask", kind: "call", src: [1] }, - { from: "api", to: "ask", label: "Answer()", kind: "call", src: [2] }, - { from: "ask", to: "ui", label: "stream", kind: "return", src: [3] }, - ], -}; +const src = "sequenceDiagram\n Ask->>httpapi: POST /api/ask\n httpapi-->>Ask: stream"; +const svg = ''; -/** jsdom has no ResizeObserver and measures nothing, so the box the view - * fits into is stated here. The layout itself never measures either - * (diagram.tsx), so the scale a test sees is the scale a browser computes. */ -function observing(width: number, height: number) { - vi.stubGlobal( - "ResizeObserver", - class { - observe() {} - disconnect() {} - }, - ); - vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height); +function view(onClose: () => void = () => {}) { + return render(); } -beforeAll(() => { - observing(400, 400); -}); - describe("DiagramView", () => { it("opens with the focus on its close button", () => { - const { getByLabelText } = render( {}} />); + const { getByLabelText } = view(); expect(document.activeElement).toBe(getByLabelText("Close")); }); + it("names the picture in its header", () => { + const { getByRole } = view(); + expect(getByRole("dialog").getAttribute("aria-label")).toBe("Sequence diagram"); + expect(getByRole("dialog").querySelector("svg .drawn")).toBeTruthy(); + }); + it("closes on Escape", () => { const onClose = vi.fn(); - render(); + view(onClose); fireEvent.keyDown(document, { key: "Escape" }); expect(onClose).toHaveBeenCalled(); }); @@ -53,7 +32,7 @@ describe("DiagramView", () => { // The click, not the press: the sheet is gone by the time the click is // dispatched, and it would otherwise land on the answer behind it. const onClose = vi.fn(); - const { getByRole } = render(); + const { getByRole } = view(onClose); const dialog = getByRole("dialog"); const scrim = dialog.parentElement as HTMLElement; @@ -69,7 +48,7 @@ describe("DiagramView", () => { it("takes a drag across the edge for a drag, not a cancel", () => { const onClose = vi.fn(); - const { getByRole } = render(); + const { getByRole } = view(onClose); const dialog = getByRole("dialog"); const scrim = dialog.parentElement as HTMLElement; @@ -87,115 +66,36 @@ describe("DiagramView", () => { it("keeps Tab inside, so it cannot reach the chips behind the scrim", () => { // Those chips are focusable groups; Enter on one would open SourceView // under this dialog, two z-30 overlays deep. - const { getByRole, getByLabelText, getByText } = render( - {}} />, - ); + const { getByRole, getByLabelText, getByText } = view(); const dialog = getByRole("dialog"); const close = getByLabelText("Close"); expect(document.activeElement).toBe(close); fireEvent.keyDown(document, { key: "Tab" }); expect(dialog.contains(document.activeElement)).toBe(true); - expect(document.activeElement).toBe(getByText("Fit")); + expect(document.activeElement).toBe(getByText("Download SVG")); fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); expect(dialog.contains(document.activeElement)).toBe(true); }); it("draws its chrome in the sans face, not the answer's serif prose", () => { - const { getByRole } = render( {}} />); + const { getByRole } = view(); expect(getByRole("dialog").className).toContain("font-sans"); }); - it("scales a diagram wider than the sheet down to fit, never up", () => { - const { container } = render( {}} />); - const size = diagramSize(seq); - const scale = Number(container.querySelector("[data-scale]")?.getAttribute("data-scale")); - expect(size.width).toBeGreaterThan(400 - 48); - expect(scale).toBeCloseTo((400 - 48) / size.width, 5); - expect(scale).toBeLessThan(1); - }); - - it("reserves the scaled size, so a fitted diagram does not also scroll", () => { - const { container } = render( {}} />); - const inner = container.querySelector("[data-scale]") as HTMLElement; - const scale = Number(inner.getAttribute("data-scale")); - const size = diagramSize(seq); - expect(inner.style.transform).toBe(`scale(${scale})`); - expect((inner.parentElement as HTMLElement).style.width).toBe(`${size.width * scale}px`); - }); - - it("offers Fit when only the height is too much for the sheet", () => { - // A long sequence in a short window is scaled by its height, and the - // toggle has to be there to undo it. - const tall: SequenceSpec = { - ...seq, - steps: Array.from({ length: 14 }, (_, i) => ({ - from: "ui" as const, - to: "api" as const, - label: `step ${i}`, - kind: "call" as const, - src: [], - })), - }; - observing(2000, 300); - const { container, getByText } = render( {}} />); - expect(diagramSize(tall).width).toBeLessThan(2000 - 48); - expect(Number(container.querySelector("[data-scale]")?.getAttribute("data-scale"))).toBeLessThan(1); - expect(getByText("Fit")).toBeTruthy(); - observing(400, 400); - }); - - it("opens at 1:1 on a phone, rather than shrinking the chips out of reach", () => { - // At 390px fitting this diagram lands near 0.34, and the 10px chips with - // it. The phone scrolls the picture, as the card behind it does. - vi.stubGlobal("matchMedia", () => ({ matches: false })); - const { container, getByText } = render( {}} />); - expect(container.querySelector("[data-scale]")?.getAttribute("data-scale")).toBe("1"); - // And the toggle is there to fit it on purpose. - expect(getByText("Fit")).toBeTruthy(); - vi.unstubAllGlobals(); - }); - - it("still fits by default where there is room for it", () => { - vi.stubGlobal("matchMedia", () => ({ matches: true })); - const { container } = render( {}} />); - expect(Number(container.querySelector("[data-scale]")?.getAttribute("data-scale"))).toBeLessThan(1); - vi.unstubAllGlobals(); - }); - - it("keeps Fit reachable on a phone, where it scales hardest", () => { - const { getByText } = render( {}} />); - expect(getByText("Fit").className).not.toContain("hidden"); - }); - - it("gives the diagram back its own size when Fit is turned off", () => { - const { container, getByText } = render( {}} />); - fireEvent.click(getByText("Fit")); - const inner = container.querySelector("[data-scale]") as HTMLElement; - expect(inner.getAttribute("data-scale")).toBe("1"); - expect(inner.style.transform).toBe(""); - }); - - it("stands aside before the source it was asked for opens", () => { - const order: string[] = []; - const onClose = () => order.push("close"); - const onOpen = () => order.push("open"); - const { container } = render( - , - ); - const chip = container.querySelector('g[role="button"]') as SVGGElement; - fireEvent.click(chip); - expect(order).toEqual(["close", "open"]); - }); - it("downloads the picture it is showing", () => { const create = vi.fn(() => "blob:x"); Object.defineProperty(URL, "createObjectURL", { value: create, configurable: true }); Object.defineProperty(URL, "revokeObjectURL", { value: vi.fn(), configurable: true }); - const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); - const { getByText } = render( {}} />); + let name = ""; + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + name = this.download; + }); + const { getByText } = view(); fireEvent.click(getByText("Download SVG")); expect(create).toHaveBeenCalled(); - expect(click).toHaveBeenCalled(); + expect(name).toBe("rongo-sequence-diagram.svg"); click.mockRestore(); }); }); diff --git a/ui/src/DiagramView.tsx b/ui/src/DiagramView.tsx index 273d76f0..7669cec3 100644 --- a/ui/src/DiagramView.tsx +++ b/ui/src/DiagramView.tsx @@ -1,47 +1,32 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { DiagramSvg, diagramSize, diagramTitle, type DiagramSpec } from "./diagram"; +import { useEffect, useRef } from "react"; +import { MermaidSvg, diagramTitle } from "./diagram"; import { download, fileName, toSvgFile } from "./diagramExport"; import { useBackdropDismiss } from "./dismiss"; import { DownloadIcon } from "./icons"; -import type { MarkerHooks } from "./markdown"; /** - * DiagramView is the diagram seen whole. In the answer the picture is drawn at - * its intrinsic width and the card scrolls sideways, so a five-actor sequence - * is read a slice at a time; here it is one picture. + * DiagramView is the diagram seen whole. In the answer the picture is scaled + * into a prose column, so a five-actor sequence is read small; here it has + * the width of the sheet. * * The shell is SourceView's, down to the z-index: the reader is stepping out * of the answer for a moment and goes straight back, and one overlay at a time - * is what the app does. That is also why a citation chip in here closes the - * view before opening its source — SourceView is z-30 as well and traps Tab - * and Escape on the document, so stacked, one Escape would close both. + * is what the app does. */ export default function DiagramView({ - spec, - hooks, + src, + svg, onClose, }: { - spec: DiagramSpec; - hooks: MarkerHooks; + src: string; + svg: string; onClose: () => void; }) { const closeButton = useRef(null); const dialog = useRef(null); const dismiss = useBackdropDismiss(onClose); const body = useRef(null); - const svg = useRef(null); - // Fitting is the default on a screen with room for it, and only there. - // Below sm the sheet is a phone's width, and fitting a seven-actor sequence - // lands near 0.34: the 10px chips reach 3.5px, which is neither readable nor - // tappable — exactly what diagram.tsx keeps the drawing unscaled to avoid. - // A phone opens at 1:1 and scrolls, as the card behind it does, and the - // toggle is there to take the whole picture in deliberately. - const [fit, setFit] = useState( - () => typeof matchMedia !== "function" || matchMedia("(min-width: 40rem)").matches, - ); - const [box, setBox] = useState({ width: 0, height: 0 }); - const title = diagramTitle(spec); - const size = diagramSize(spec); + const title = diagramTitle(src); // Focus moves into the dialog on open and back to where it was on close, as // SourceView does. @@ -55,8 +40,7 @@ export default function DiagramView({ // simply refocus it; this dialog has several, so the ends of the ring wrap // to each other. Without it, Tab reaches the citation chips in the answer // behind the scrim — they are focusable groups — and Enter there would open - // a second z-30 overlay under this one, which is the very thing the chip - // hand-off above stands aside to avoid. + // a second z-30 overlay under this one. useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === "Escape") { @@ -81,36 +65,8 @@ export default function DiagramView({ return () => document.removeEventListener("keydown", onKey); }, [onClose]); - // The available box, measured rather than assumed: the sheet is a share of - // the window, and the window is anything from a phone to a desktop. - useLayoutEffect(() => { - const el = body.current; - if (!el) return; - function read() { - if (el) setBox({ width: el.clientWidth, height: el.clientHeight }); - } - read(); - if (typeof ResizeObserver !== "function") return; - const ro = new ResizeObserver(read); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - // Down only, never up: a small diagram blown up to fill the sheet is a - // blurry-looking drawing with 30px labels, and the point here is to see the - // whole picture, not a bigger one. - const room = { width: box.width - 48, height: box.height - 48 }; - const scale = - fit && room.width > 0 && room.height > 0 - ? Math.min(1, room.width / size.width, room.height / size.height) - : 1; - // Either dimension: a long sequence in a short window is scaled down by its - // height, and a toggle that stayed hidden would leave the reader with a - // shrunken picture and no way back to 1:1. - const overflows = room.width > 0 && (size.width > room.width || size.height > room.height); - function save() { - if (svg.current) download(fileName(spec), toSvgFile(svg.current)); + download(fileName(src), toSvgFile({ svg }, body.current)); } return ( @@ -123,9 +79,9 @@ export default function DiagramView({ onPointerUp={dismiss.onPointerUp} > {/* font-sans explicitly: this is mounted from inside the answer's - .ui-markdown wrapper, which is serif prose, and the dialog is chrome - — the same reason DiagramSvg names it. SourceView needs no such line - because Ask.tsx mounts it outside the prose. */} + .ui-markdown wrapper, which is serif prose, and the dialog is chrome. + SourceView needs no such line because Ask.tsx mounts it outside the + prose. */}
{title} - {/* Only offered when there is something to fit: on a diagram that - already stands whole in the sheet the toggle would do nothing, - and a control that does nothing reads as broken. */} - {/* Never hidden on a phone, unlike the pills beside it: that is the - width where fitting scales a wide diagram hardest, and this is - the only way back to the size the labels were drawn at. */} - {(overflows || !fit) && ( - - )} @@ -721,15 +372,13 @@ export default function Diagram({ spec, hooks }: { spec: DiagramSpec; hooks: Mar onClick={save} title="Download SVG" aria-label={`${title}: download SVG`} - className="pointer-events-auto grid h-7 w-7 place-items-center rounded-ui-sm text-faint hover:bg-active hover:text-ink-dim" + className="grid h-7 w-7 place-items-center rounded-ui-sm text-faint hover:bg-active hover:text-ink-dim" >
-
- -
- {full && setFull(false)} />} + + {full && setFull(false)} />} ); } diff --git a/ui/src/diagramExport.test.ts b/ui/src/diagramExport.test.ts index a72ec94d..1d915d4f 100644 --- a/ui/src/diagramExport.test.ts +++ b/ui/src/diagramExport.test.ts @@ -128,9 +128,9 @@ describe("mermaidize", () => { expect(mermaidize(src)).toBe(src); }); - it("leaves a diagram fence that does not parse exactly as it is", () => { - const src = "```diagram\n{not json\n```\n"; - expect(mermaidize(src)).toBe(src); + it("retags a diagram fence holding mermaid text, which draws here and nowhere else", () => { + const src = "```diagram\nflowchart TD\n a --> b\n```\n"; + expect(mermaidize(src)).toBe("```mermaid\nflowchart TD\n a --> b\n```\n"); }); it("leaves an unclosed diagram fence as the text it is", () => { @@ -144,69 +144,42 @@ describe("toSvgFile", () => { document.head.querySelectorAll("style[data-test]").forEach((s) => s.remove()); }); - function drawn(): SVGSVGElement { + const svg = ''; + + function card(): HTMLElement { const style = document.createElement("style"); style.setAttribute("data-test", "1"); - style.textContent = - ".fill-active { fill: #2c2c2a; } .stroke-border { stroke: #323230; } .card { background-color: #1b1b1a; }"; + style.textContent = ".card { background-color: #1b1b1a; }"; document.head.appendChild(style); const host = document.createElement("div"); host.className = "card"; - host.innerHTML = - '' + - "Sequence diagram" + - '' + - '' + - ""; document.body.appendChild(host); - return host.querySelector("svg") as SVGSVGElement; + return host; } - it("inlines the colours the classes carried", () => { - const out = toSvgFile(drawn()); - expect(out).toContain('fill="rgb(44, 44, 42)"'); - expect(out).toContain('stroke="rgb(50, 50, 48)"'); - }); - - it("leaves the elements that carry no paint unpainted", () => { - const out = toSvgFile(drawn()); - expect(out).toMatch(/]*>/); - expect(out).not.toMatch(/]*fill=/); - }); - - it("leaves no class behind for a viewer that has no stylesheet", () => { - expect(toSvgFile(drawn())).not.toContain("class="); - }); - - it("drops the chips' hit rects, which are invisible in a file", () => { - expect(toSvgFile(drawn())).not.toContain('width="30"'); - }); - it("carries the card's ground, so the pale labels are not lost on white", () => { - const out = toSvgFile(drawn()); - expect(out).toMatch(//); + const out = toSvgFile({ svg }, card()); + expect(out).toContain(''); // First, so it sits behind the drawing rather than over it. expect(out.indexOf('fill="rgb(27, 27, 26)"')).toBeLessThan(out.indexOf('width="10"')); }); - it("adds a viewBox so the file scales in a viewer", () => { - const out = toSvgFile(drawn()); + it("leaves the renderer's file alone otherwise", () => { + expect(toSvgFile({ svg }, null)).toBe(svg); + const out = toSvgFile({ svg }, card()); expect(out).toContain('viewBox="0 0 240 90"'); - expect(out).toContain("http://www.w3.org/2000/svg"); - expect(out).toContain("Sequence diagram"); - }); - - it("keeps the drawing out of the live tree", () => { - const el = drawn(); - toSvgFile(el); - expect(el.querySelectorAll('[data-export="skip"]').length).toBe(1); + expect(out).toContain(""); }); }); describe("fileName", () => { it("names the file after the kind of picture", () => { - expect(fileName(seq)).toBe("rongo-sequence-diagram.svg"); - expect(fileName(flow)).toBe("rongo-flow-diagram.svg"); + expect(fileName("sequenceDiagram\n A->>B: x")).toBe("rongo-sequence-diagram.svg"); + expect(fileName("flowchart TD\n a")).toBe("rongo-flowchart-diagram.svg"); + expect(fileName("graph LR\n a")).toBe("rongo-flowchart-diagram.svg"); + expect(fileName("stateDiagram-v2\n a")).toBe("rongo-state-diagram.svg"); + expect(fileName("erDiagram\n A")).toBe("rongo-er-diagram.svg"); + expect(fileName("")).toBe("rongo-diagram.svg"); }); }); diff --git a/ui/src/diagramExport.ts b/ui/src/diagramExport.ts index 2b0db1fa..f0360d9b 100644 --- a/ui/src/diagramExport.ts +++ b/ui/src/diagramExport.ts @@ -1,88 +1,32 @@ -import { parseDiagram, type DiagramSpec, type FlowKind } from "./diagram"; +import { diagramKind, parseDiagram, type DiagramSpec, type FlowKind } from "./diagram"; import { fenceRe } from "./markdown"; /** * Taking a diagram out of rongo: as a standalone .svg file, and as mermaid * inside the copied markdown. * - * Both exist because the drawing in the answer is only readable inside the - * app. The SVG on the page carries no colours of its own — every fill and - * stroke is a Tailwind utility bound to a theme token — so a serialized node - * lands in a viewer as flat black on white. And "Copy as Markdown" copies the - * answer text, which holds the diagram as its `diagram` JSON fence: correct, - * and unreadable everywhere outside rongo. + * The file is the renderer's own SVG with a ground put under it. "Copy as + * Markdown" copies the answer text; a `mermaid` fence in it already reads + * everywhere, and the older `diagram` JSON fence is rewritten to one, since + * that shape is rongo's own and draws nowhere else. */ // ---- SVG file ---- -/** The properties a shape in diagram.tsx gets from a class rather than an - * attribute. Read off the live node, so the file follows the @theme tokens - * instead of a second copy of them kept here. */ -const inlined = [ - "fill", - "stroke", - "stroke-width", - "stroke-dasharray", - "font-family", - "font-size", - "font-weight", -] as const; - -/** Elements that carry no paint of their own. */ -const skipPaint = new Set(["svg", "title", "desc", "defs", "marker"]); - -/** toSvgFile turns a drawn diagram into a file that stands on its own: the - * colours inlined, the classes dropped, and a viewBox added. The viewBox is - * deliberate here and deliberately absent on the page — a file is opened in a - * viewer that scales it to the window, while the element in the answer must - * keep its intrinsic width and scroll (diagram.tsx). */ -export function toSvgFile(el: SVGSVGElement): string { - const clone = el.cloneNode(true) as SVGSVGElement; - const live = [el, ...el.querySelectorAll("*")]; - const copy = [clone, ...clone.querySelectorAll("*")]; - for (let i = 0; i < live.length && i < copy.length; i++) { - // Only what draws. Painting the root, the or a <defs> wrapper - // would put a colour on elements that have none and leave the file - // carrying a paragraph of inherited defaults per node. - if (skipPaint.has(copy[i].tagName)) continue; - const style = getComputedStyle(live[i] as Element); - for (const prop of inlined) { - const v = style.getPropertyValue(prop); - if (v !== "") copy[i].setAttribute(prop, v); - } - } - // The chips' hit rects are drawn only because an SVG group cannot take - // padding; in a file they are invisible rectangles over the drawing. - for (const hit of clone.querySelectorAll('[data-export="skip"]')) hit.remove(); - for (const node of copy) { - node.removeAttribute("class"); - node.removeAttribute("role"); - node.removeAttribute("tabindex"); - for (const a of [...node.attributes]) { - if (a.name.startsWith("aria-") || a.name.startsWith("data-")) node.removeAttribute(a.name); - } - } - const w = el.getAttribute("width") ?? "0"; - const h = el.getAttribute("height") ?? "0"; - // The drawing's ground travels with it. On the page the dark panel is the - // card's, not the SVG's; in a file, a viewer paints white behind it and the - // warm off-white labels all but disappear. - const ground = groundOf(el); - if (ground !== null) { - const rect = clone.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("width", w); - rect.setAttribute("height", h); - rect.setAttribute("fill", ground); - clone.insertBefore(rect, clone.firstChild); - } - clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - clone.setAttribute("viewBox", `0 0 ${w} ${h}`); - return new XMLSerializer().serializeToString(clone); +/** toSvgFile turns a rendered diagram into a file that stands on its own. + * The renderer already inlines its stylesheet and a viewBox, so the string + * lacks only its ground: on the page the dark panel is the card's, not the + * SVG's, and in a file a viewer paints white behind it and the warm off-white + * labels all but disappear. The ground is read off the card rather than + * named here, so there is one copy of the token. */ +export function toSvgFile(drawn: { svg: string }, card: Element | null): string { + const ground = card ? groundOf(card) : null; + if (ground === null) return drawn.svg; + return drawn.svg.replace(/<svg\b[^>]*>/, (open) => `${open}<rect width="100%" height="100%" fill="${ground}"/>`); } -/** groundOf is the first real background behind the drawing. Walked rather - * than named, for the same reason the paint is read and not tabulated: the - * colour is a theme token, and there should be one copy of it. */ +/** groundOf is the first real background behind the drawing, walked up from + * the card. */ function groundOf(el: Element): string | null { for (let node: Element | null = el; node; node = node.parentElement) { const bg = getComputedStyle(node).backgroundColor; @@ -106,8 +50,10 @@ export function download(name: string, svg: string): void { /** fileName is what the download is called: the kind of picture it is, so a * folder of them stays sortable. */ -export function fileName(spec: DiagramSpec): string { - return spec.type === "flow" ? "rongo-flow-diagram.svg" : "rongo-sequence-diagram.svg"; +export function fileName(src: string): string { + const kind = diagramKind(src).replace(/Diagram(-v2)?$/, "").toLowerCase(); + if (kind === "") return "rongo-diagram.svg"; + return `rongo-${kind === "graph" ? "flowchart" : kind}-diagram.svg`; } // ---- mermaid ---- @@ -171,9 +117,9 @@ function bare(label: string): string { return label.replace(/[;#]/g, "").replace(/\s+/g, " ").trim(); } -/** markers renders a step's sources the way the prose writes them, so the +/** cited renders a step's sources the way the prose writes them, so the * numbers in a pasted diagram still point into the `Sources:` block under it. */ -function markers(src: number[]): string { +function cited(src: number[]): string { return src.map((m) => ` [${m}]`).join(""); } @@ -187,8 +133,15 @@ function shape(kind: FlowKind, label: string): string { } /** toMermaid writes the spec as the diagram syntax GitHub, GitLab, Obsidian - * and Notion draw, and that reads as plain text everywhere else. */ -export function toMermaid(spec: DiagramSpec): string { + * and Notion draw, and that reads as plain text everywhere else. + * + * cite says whether a node's markers go into its label. On the clipboard + * they do: the numbers point into the Sources block pasted under the text. + * Drawn in the answer they do not: the picture no longer cites, and a spec + * the backend never renumbered would draw the prompt's numbers as if they + * were the reader's. */ +export function toMermaid(spec: DiagramSpec, cite = true): string { + const markers = cite ? cited : () => ""; if (spec.type === "sequence") { const id = safeIds(spec.actors.map((a) => a.id)); const out = ["sequenceDiagram"]; @@ -211,9 +164,10 @@ export function toMermaid(spec: DiagramSpec): string { return out.join("\n"); } -/** mermaidize rewrites the diagram fences in an answer for the clipboard. A - * fence that does not parse is left exactly as it is: it is not a diagram - * this renderer drew either, and the reader gets what the answer said. */ +/** mermaidize rewrites the older diagram fences in an answer for the + * clipboard: a JSON spec becomes mermaid text, and a `diagram` tag over + * mermaid text (which diagram.tsx draws) becomes the `mermaid` tag every + * other renderer knows. */ export function mermaidize(text: string): string { const lines = text.split("\n"); const out: string[] = []; @@ -228,14 +182,13 @@ export function mermaidize(text: string): string { const body: string[] = []; while (i < lines.length && !fenceRe.test(lines[i])) body.push(lines[i++]); const closed = i < lines.length; - const close = closed ? lines[i++] : null; - const spec = closed ? parseDiagram(body.join("\n")) : null; - if (!spec) { + if (closed) i++; + if (!closed) { out.push(open, ...body); - if (close !== null) out.push(close); continue; } - out.push("```mermaid", ...toMermaid(spec).split("\n"), "```"); + const spec = parseDiagram(body.join("\n")); + out.push("```mermaid", ...(spec ? toMermaid(spec).split("\n") : body), "```"); } return out.join("\n"); } diff --git a/ui/src/index.css b/ui/src/index.css index d2fe1bc4..9555e281 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -212,6 +212,17 @@ body { margin-bottom: 0; } +/* The renderer's SVG carries a viewBox and its own intrinsic max-width, so + it scales down into a narrow column and never past 1:1. Block, so the card + holds no inline-box gap under it. The colours are the theme's, handed to + the renderer as its theme variables (diagram.tsx themeVariables). */ +.rongo-diagram svg { + display: block; + margin-inline: auto; + max-width: 100%; + height: auto; +} + .ui-markdown :where(h1, h2, h3, h4) { margin: 1.1rem 0 0.45rem; color: var(--color-ink); diff --git a/ui/src/markdown.test.tsx b/ui/src/markdown.test.tsx index b0489be8..0cb2f8bd 100644 --- a/ui/src/markdown.test.tsx +++ b/ui/src/markdown.test.tsx @@ -1,6 +1,20 @@ import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; + +// The diagram renderer measures text, which jsdom cannot; see diagram.test.tsx. +vi.mock("mermaid", () => ({ + default: { + initialize: vi.fn(), + parse: async (src: string) => { + if (!/^(flowchart|sequenceDiagram|stateDiagram-v2|erDiagram)\b/.test(src.trim())) { + throw new Error("Parse error"); + } + }, + render: async (id: string, src: string) => ({ svg: `<svg id="${id}"><g class="drawn">${src.length}</g></svg>` }), + }, +})); + import Markdown, { splitIntoSegments } from "./markdown"; describe("Markdown", () => { @@ -371,8 +385,8 @@ describe("Markdown", () => { }); }); - // The one fence that is not code. Its nodes carry markers, so it is drawn, - // not shown — but only when it is complete and says something drawable. + // The one fence that is not code. It is drawn, not shown, but only when it + // is complete; the older JSON spec draws through the same door. describe("diagram fence", () => { const spec = JSON.stringify({ type: "flow", @@ -382,34 +396,41 @@ describe("Markdown", () => { ], edges: [{ from: "a", to: "b" }], }); + const mermaid = "flowchart LR\n a[\"Answer()\"] --> b[\"stored\"]"; - it("is drawn as SVG, not shown as text", () => { - const { container } = render(<Markdown text={"Before.\n\n```diagram\n" + spec + "\n```\n\nAfter."} />); - expect(container.querySelector("svg")).toBeTruthy(); + it("is drawn as SVG, not shown as text", async () => { + const { container } = render(<Markdown text={"Before.\n\n```mermaid\n" + mermaid + "\n```\n\nAfter."} />); + await waitFor(() => expect(container.querySelector("svg .drawn")).toBeTruthy()); expect(container.querySelector("pre")).toBeNull(); - expect(container.textContent).not.toContain('"type"'); + expect(container.textContent).not.toContain("flowchart"); expect(container.textContent).toContain("Before."); expect(container.textContent).toContain("After."); }); - it("carries the citation hooks into the picture", () => { - const { container } = render(<Markdown text={"```diagram\n" + spec + "\n```"} backed={new Set([1])} />); - expect(container.querySelectorAll("rect.fill-accent-dim").length).toBe(1); - expect(Array.from(container.querySelectorAll("text.fill-muted")).some((t) => t.textContent === "[2]")).toBe(true); + it("still draws the older JSON spec", async () => { + const { container } = render(<Markdown text={"```diagram\n" + spec + "\n```"} />); + await waitFor(() => expect(container.querySelector("svg .drawn")).toBeTruthy()); + expect(container.textContent).not.toContain('"type"'); }); - it("says so when a block meant to be a diagram did not draw", () => { + it("says so when a block meant to be a diagram did not draw", async () => { // For three releases this looked like an ordinary code block and the // reader could not tell whether the model or the renderer had slipped. const { container } = render(<Markdown text={'```diagram\n{"type":"flow","nodes":[{"id"\n```'} />); + await waitFor(() => expect(container.textContent).toContain("Diagram could not be drawn")); expect(container.querySelector("svg")).toBeNull(); - expect(container.textContent).toContain("Diagram could not be drawn"); expect(container.querySelector("pre code")?.textContent).toBe('{"type":"flow","nodes":[{"id"'); }); - it("says the same about a spec the model fenced as something else", () => { + it("says the same about a spec the model fenced as something else", async () => { const { container } = render(<Markdown text={'```json\n{"type":"sequence","actors":[{"id"\n```'} />); - expect(container.textContent).toContain("Diagram could not be drawn"); + await waitFor(() => expect(container.textContent).toContain("Diagram could not be drawn")); + }); + + it("says the same about diagram syntax the renderer refuses", async () => { + const { container } = render(<Markdown text={"```mermaid\npie\n x: 1\n```"} />); + await waitFor(() => expect(container.textContent).toContain("Diagram could not be drawn")); + expect(container.querySelector("pre code")?.textContent).toBe("pie\n x: 1"); }); it("leaves an ordinary code block alone", () => { diff --git a/ui/src/markdown.tsx b/ui/src/markdown.tsx index 7cc9ed3e..b6abba7c 100644 --- a/ui/src/markdown.tsx +++ b/ui/src/markdown.tsx @@ -1,16 +1,17 @@ import { memo } from "react"; import type { JSX, ReactNode } from "react"; import { highlightBlock, languageOf } from "./highlight"; -import Diagram, { parseDiagram } from "./diagram"; +import Diagram, { diagramSource } from "./diagram"; /** * A small Markdown renderer covering exactly what the answer prompt produces: * headings, paragraphs, bold, inline code, fenced code (coloured by its * language tag, see highlight.tsx), lists, pipe tables, and the one fence that - * is not code: a `diagram` fence, drawn by diagram.tsx. + * is not code: a `mermaid` fence, drawn by diagram.tsx. * * It builds React nodes and never HTML. The text is model output, and * dangerouslySetInnerHTML would turn a prompt injection into a script tag. + * The one exception is the diagram, and diagram.tsx says what guards it. * * There is deliberately NO link syntax. Citation markers like [1] are what * makes an answer checkable; a renderer that consumed one — link parsing, @@ -249,12 +250,6 @@ function inline(src: string, key: string, hooks: MarkerHooks, fade: boolean): Re * Exported for diagramExport.ts, which walks the same fences to rewrite a * diagram for the clipboard. */ export const fenceRe = /^\s*```\s*([\w+#-]*)/; -/** specRe says a fence body that opens as a diagram spec. Anchored on the - * brace: an answer explaining the diagram format, or quoting a config that - * happens to hold a "type" of "flow", carries the same words somewhere in a - * code block, and labelling that block a failed diagram would hide real code - * behind a defect notice. */ -const specRe = /^\s*\{\s*"type"\s*:\s*"(?:flow|sequence)"/; const headingRe = /^(#{1,6})\s+(.*)$/; const bulletRe = /^\s*[-*]\s+(.*)$/; const orderedRe = /^\s*\d+[.)]\s+(.*)$/; @@ -359,19 +354,20 @@ export function renderMarkdown(src: string, hooks: MarkerHooks = {}, fade = fals const closed = i < lines.length; i++; let tag = fence[1]; - const wasDiagram = tag === "diagram"; - if (tag === "diagram") { - // A diagram fence is drawn once it is complete and parses. While it - // is still arriving the reader sees that a picture is on its way, not - // the JSON flashing by. Once the citations are known the turn is over: - // a fence still open then (a stream cut, or a stored answer holding - // one) is shown as the text it is, never a placeholder that waits - // for nothing. - const spec = closed ? parseDiagram(body.join("\n")) : null; - if (spec) { - out.push(<Diagram key={k++} spec={spec} hooks={hooks} />); - continue; - } + const src = closed ? diagramSource(tag, body.join("\n")) : null; + if (src !== null) { + // A diagram fence is drawn once it is complete; the card draws it, + // says so while the renderer works, and says so when the renderer + // refuses it, keeping the source underneath. + out.push(<Diagram key={k++} src={src} />); + continue; + } + if (tag === "diagram" || tag === "mermaid") { + // While the fence is still arriving the reader sees that a picture + // is on its way, not the source flashing by. Once the citations are + // known the turn is over: a fence still open then (a stream cut, or + // a stored answer holding one) is shown as the text it is, never a + // placeholder that waits for nothing. if (!closed && hooks.backed === undefined) { out.push( <div @@ -383,28 +379,7 @@ export function renderMarkdown(src: string, hooks: MarkerHooks = {}, fade = fals ); continue; } - tag = "json"; - } - // A block that says it is a diagram and did not draw is a defect, and - // for three releases it looked like an ordinary code block: the reader - // saw JSON where a picture belonged and nobody could tell whether the - // model or the renderer had slipped. It says so now, and keeps the - // spec underneath so the next one can be diagnosed at a glance. - // `wasDiagram` is a block the backend already agreed was one; any other - // tag has to look like a spec from its first character. - if (closed && (wasDiagram || specRe.test(body.join("\n")))) { - out.push( - <details - key={k++} - className="mt-3 rounded-ui-sm border border-border bg-panel p-3 font-sans text-sm text-muted" - > - <summary className="cursor-pointer">Diagram could not be drawn</summary> - <pre className="mt-2 overflow-x-auto font-mono text-[13px] leading-relaxed"> - <code>{body.join("\n")}</code> - </pre> - </details>, - ); - continue; + tag = ""; } // A fence still arriving ends on the newline its last line was written // with, and that empty line is not content yet — the next token fills From 4054345d2ca9dc441ce181a32a41efb715ed01f7 Mon Sep 17 00:00:00 2001 From: trick77 <trick77@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:22:44 +0200 Subject: [PATCH 2/3] Ground the SVG file over its viewBox, retry a failed render The renderer's viewBox starts above and left of the origin, so a ground rect at 0,0 left a bare band along two edges of the downloaded file. A render that failed is no longer cached: the first diagram of a type loads its chunk, and a chunk missing after a redeploy would otherwise follow the source for the rest of the session. --- ui/src/diagram.test.tsx | 6 ++++++ ui/src/diagram.tsx | 12 +++++++++++- ui/src/diagramExport.test.ts | 7 ++++--- ui/src/diagramExport.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/ui/src/diagram.test.tsx b/ui/src/diagram.test.tsx index c04c2a5b..52592bfa 100644 --- a/ui/src/diagram.test.tsx +++ b/ui/src/diagram.test.tsx @@ -164,6 +164,12 @@ describe("draw", () => { expect(out).toEqual({ error: "Parse error on line 1" }); expect(renderSvg).not.toHaveBeenCalled(); }); + + it("keeps a drawing but not a refusal, so a chunk that failed to load is tried again", async () => { + await draw("flowchart LR\n bad[ again"); + await draw("flowchart LR\n bad[ again"); + expect(parse).toHaveBeenCalledTimes(2); + }); }); describe("Diagram", () => { diff --git a/ui/src/diagram.tsx b/ui/src/diagram.tsx index 4861337b..46b9be8a 100644 --- a/ui/src/diagram.tsx +++ b/ui/src/diagram.tsx @@ -260,11 +260,20 @@ function init(): void { export type Drawn = { svg: string } | { error: string }; /** One drawing per source text, however many times the answer re-renders. - * The stream re-renders on every token, and a closed fence is drawn once. */ + * The stream re-renders on every token, and a closed fence is drawn once. + * Never evicted: a session's diagrams are a few strings, and the same + * string mounted twice (the card and the full view) is one drawing. */ const drawn = new Map<string, Promise<Drawn>>(); let seq = 0; /** draw renders a source to an SVG string, or to the parser's complaint. + * + * Only a drawing is kept. The renderer loads each diagram type as its own + * chunk, so the first sequence or entity diagram of a session can fail for + * a reason that is not the source (a redeploy took the hashed chunk away, a + * network blip); kept, that failure would follow the source for the rest of + * the session. A refused source is cheap to refuse again. + * * Exported for the tests, which mock the renderer and assert on the cache. */ export function draw(src: string): Promise<Drawn> { let p = drawn.get(src); @@ -276,6 +285,7 @@ export function draw(src: string): Promise<Drawn> { const { svg } = await mermaid.render(`rongo-diagram-${++seq}`, src); return { svg }; } catch (e) { + drawn.delete(src); return { error: e instanceof Error ? e.message : String(e) }; } })(); diff --git a/ui/src/diagramExport.test.ts b/ui/src/diagramExport.test.ts index 1d915d4f..8f72ebd1 100644 --- a/ui/src/diagramExport.test.ts +++ b/ui/src/diagramExport.test.ts @@ -144,7 +144,7 @@ describe("toSvgFile", () => { document.head.querySelectorAll("style[data-test]").forEach((s) => s.remove()); }); - const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 90"><style>.x{}</style><rect width="10" height="10"/></svg>'; + const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -25 240 90"><style>.x{}</style><rect width="10" height="10"/></svg>'; function card(): HTMLElement { const style = document.createElement("style"); @@ -159,7 +159,8 @@ describe("toSvgFile", () => { it("carries the card's ground, so the pale labels are not lost on white", () => { const out = toSvgFile({ svg }, card()); - expect(out).toContain('<rect width="100%" height="100%" fill="rgb(27, 27, 26)"/>'); + // Over the viewBox, which starts above and left of the origin. + expect(out).toContain('<rect x="-8" y="-25" width="240" height="90" fill="rgb(27, 27, 26)"/>'); // First, so it sits behind the drawing rather than over it. expect(out.indexOf('fill="rgb(27, 27, 26)"')).toBeLessThan(out.indexOf('width="10"')); }); @@ -167,7 +168,7 @@ describe("toSvgFile", () => { it("leaves the renderer's file alone otherwise", () => { expect(toSvgFile({ svg }, null)).toBe(svg); const out = toSvgFile({ svg }, card()); - expect(out).toContain('viewBox="0 0 240 90"'); + expect(out).toContain('viewBox="-8 -25 240 90"'); expect(out).toContain("<style>.x{}</style>"); }); }); diff --git a/ui/src/diagramExport.ts b/ui/src/diagramExport.ts index f0360d9b..a340acce 100644 --- a/ui/src/diagramExport.ts +++ b/ui/src/diagramExport.ts @@ -22,7 +22,15 @@ import { fenceRe } from "./markdown"; export function toSvgFile(drawn: { svg: string }, card: Element | null): string { const ground = card ? groundOf(card) : null; if (ground === null) return drawn.svg; - return drawn.svg.replace(/<svg\b[^>]*>/, (open) => `${open}<rect width="100%" height="100%" fill="${ground}"/>`); + return drawn.svg.replace(/<svg\b[^>]*>/, (open) => { + // The ground covers the viewBox, not the user-space origin: the + // renderer's viewBox starts left of and above 0 (a sequence at y -25, + // a flowchart at minus its padding), and a rect at 0,0 would leave a + // bare band along the top and left edge of the file. + const vb = /viewBox="([^"]*)"/.exec(open); + const [x, y, w, h] = vb ? vb[1].trim().split(/[\s,]+/) : ["0", "0", "100%", "100%"]; + return `${open}<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${ground}"/>`; + }); } /** groundOf is the first real background behind the drawing, walked up from From 66aefef3692385ffc629811be25e5af7dfb1acaa Mon Sep 17 00:00:00 2001 From: trick77 <trick77@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:35:35 +0200 Subject: [PATCH 3/3] Corpus: four answers as the model wrote them on the flow corpus --- .../diagrams/eval-guest-cart-en.golden | 26 ++++++++++ .../testdata/diagrams/eval-guest-cart-en.txt | 26 ++++++++++ .../diagrams/eval-order-services-de.golden | 47 +++++++++++++++++++ .../diagrams/eval-order-services-de.txt | 47 +++++++++++++++++++ .../diagrams/eval-payment-declined-de.golden | 28 +++++++++++ .../diagrams/eval-payment-declined-de.txt | 28 +++++++++++ .../diagrams/eval-queue-down-en.golden | 14 ++++++ .../testdata/diagrams/eval-queue-down-en.txt | 14 ++++++ 8 files changed, 230 insertions(+) create mode 100644 backend/internal/ask/testdata/diagrams/eval-guest-cart-en.golden create mode 100644 backend/internal/ask/testdata/diagrams/eval-guest-cart-en.txt create mode 100644 backend/internal/ask/testdata/diagrams/eval-order-services-de.golden create mode 100644 backend/internal/ask/testdata/diagrams/eval-order-services-de.txt create mode 100644 backend/internal/ask/testdata/diagrams/eval-payment-declined-de.golden create mode 100644 backend/internal/ask/testdata/diagrams/eval-payment-declined-de.txt create mode 100644 backend/internal/ask/testdata/diagrams/eval-queue-down-en.golden create mode 100644 backend/internal/ask/testdata/diagrams/eval-queue-down-en.txt diff --git a/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.golden b/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.golden new file mode 100644 index 00000000..3fafbf66 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.golden @@ -0,0 +1,26 @@ +When a guest logs in, the items from their anonymous session cart are merged into the customer's persistent cart, and the session cart is then deleted. [1][2] + +**Before login**, the shopping cart is identified by the visitor's browser session ID. The helper function that determines which cart to use returns the session ID for guests and the customer ID for logged-in users, so all add-to-cart, update, and view operations attach to an anonymous cart while the visitor is not logged in [3][4]. + +**During login**, the front-end first authenticates the user and retrieves their customer ID [2][5]. As an immediate next step in the same sequence of asynchronous calls, the front-end sends a merge request to the carts service, passing both the customer ID and the current session ID as parameters [2][5]. This is a fixed part of the login flow, not a configuration choice. + +**The merge itself** is a single operation on the carts service. It loads both the customer's cart and the session cart, then iterates over every item in the session cart and adds it to the customer's cart [1][6]. After the items have been transferred, the session cart is deleted from storage so that the anonymous cart no longer exists [1]. If the customer already had items from a previous visit, the guest items are added alongside them—the session cart's contents do not overwrite the customer's existing cart [6]. + +**Error handling**: if the merge request fails (for example, a network error reaching the carts service), the front-end logs the error but does not block the login itself. The user still becomes logged in and receives a cookie, but their guest cart items are effectively lost [7][8]. This means the merge is best-effort rather than transactional with the login. + +```mermaid +sequenceDiagram + participant Browser + participant FrontEnd + participant UserService + participant CartsService + Browser->>FrontEnd: Login request + FrontEnd->>UserService: Authenticate user + UserService-->>FrontEnd: Customer ID + FrontEnd->>CartsService: Merge session cart into customer cart + Note over CartsService: Add session items to customer cart, delete session cart + CartsService-->>FrontEnd: Merge response + FrontEnd-->>Browser: Login cookie set +``` + +After this sequence completes, the visitor is logged in, their cart is identified by customer ID rather than session ID, and all previously anonymous items are now part of that customer's cart [3][8]. The entire merge-then-delete behavior is implemented in application code, not controlled by configuration. diff --git a/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.txt b/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.txt new file mode 100644 index 00000000..3fafbf66 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-guest-cart-en.txt @@ -0,0 +1,26 @@ +When a guest logs in, the items from their anonymous session cart are merged into the customer's persistent cart, and the session cart is then deleted. [1][2] + +**Before login**, the shopping cart is identified by the visitor's browser session ID. The helper function that determines which cart to use returns the session ID for guests and the customer ID for logged-in users, so all add-to-cart, update, and view operations attach to an anonymous cart while the visitor is not logged in [3][4]. + +**During login**, the front-end first authenticates the user and retrieves their customer ID [2][5]. As an immediate next step in the same sequence of asynchronous calls, the front-end sends a merge request to the carts service, passing both the customer ID and the current session ID as parameters [2][5]. This is a fixed part of the login flow, not a configuration choice. + +**The merge itself** is a single operation on the carts service. It loads both the customer's cart and the session cart, then iterates over every item in the session cart and adds it to the customer's cart [1][6]. After the items have been transferred, the session cart is deleted from storage so that the anonymous cart no longer exists [1]. If the customer already had items from a previous visit, the guest items are added alongside them—the session cart's contents do not overwrite the customer's existing cart [6]. + +**Error handling**: if the merge request fails (for example, a network error reaching the carts service), the front-end logs the error but does not block the login itself. The user still becomes logged in and receives a cookie, but their guest cart items are effectively lost [7][8]. This means the merge is best-effort rather than transactional with the login. + +```mermaid +sequenceDiagram + participant Browser + participant FrontEnd + participant UserService + participant CartsService + Browser->>FrontEnd: Login request + FrontEnd->>UserService: Authenticate user + UserService-->>FrontEnd: Customer ID + FrontEnd->>CartsService: Merge session cart into customer cart + Note over CartsService: Add session items to customer cart, delete session cart + CartsService-->>FrontEnd: Merge response + FrontEnd-->>Browser: Login cookie set +``` + +After this sequence completes, the visitor is logged in, their cart is identified by customer ID rather than session ID, and all previously anonymous items are now part of that customer's cart [3][8]. The entire merge-then-delete behavior is implemented in application code, not controlled by configuration. diff --git a/backend/internal/ask/testdata/diagrams/eval-order-services-de.golden b/backend/internal/ask/testdata/diagrams/eval-order-services-de.golden new file mode 100644 index 00000000..67f129e3 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-order-services-de.golden @@ -0,0 +1,47 @@ +## Dienste, die der Bestelldienst vor dem Speichern abfragt + +Der Bestelldienst ruft vor dem Speichern einer Bestellung insgesamt **sechs Dienste** auf: vier parallel zu Beginn (Benutzer-, Adressen-, Karten- und Warenkorbdienst), dann den Zahlungsdienst zur Autorisierung und bei Erfolg den Versanddienst zur Erstellung einer Lieferung [1][2]. + +Zuerst startet der Bestelldienst vier parallele asynchrone HTTP-GET-Aufrufe. Über die in der Bestellanfrage übergebenen URLs fragt er den **Benutzerdienst** nach den Kundendaten, den **Adressendienst** nach der Rechnungsadresse, den **Kartendienst** nach den Zahlungskartendetails und den **Warenkorbdienst** nach der Artikelliste samt Stückpreisen ab [2]. Die Adress-URL zeigt auf den Benutzerdienst (`http://user.<domain>/addresses/…`) [3]. + +Sobald die vier Antworten vorliegen, berechnet der Bestelldienst den Gesamtbetrag – Artikelpreise zzgl. pauschaler Versandkosten in Höhe von 4,99 Einheiten [4] – und sendet diesen zusammen mit den Adress-, Karten- und Kundendaten an den **Zahlungsdienst** per HTTP-POST (Konfigurationspfad: `payment`) [1][2][5]. Der Zahlungsdienst prüft, ob der Betrag autorisiert werden kann [6]. Ist die Zahlung nicht autorisiert oder die Antwort nicht lesbar, wird die Bestellung mit einer entsprechenden Meldung abgelehnt [1]. + +Erst nach erfolgreicher Zahlungsautorisation erstellt der Bestelldienst einen neuen Versandauftrag über den **Versanddienst** per HTTP-POST (Konfigurationspfad: `shipping`) [1][7]. Die Kunden-ID wird aus der zuvor erhaltenen Kundenressource extrahiert [8]. Erst danach werden alle gesammelten Daten – Kunde, Adresse, Karte, Artikel, Lieferung, Datum und Gesamtbetrag – zu einer Bestellung zusammengefügt und in der Datenbank gespeichert [1]. + +Die nachfolgende Abbildung zeigt den Ablauf inklusive der Verzweigung bei nicht autorisierter Zahlung: + +```mermaid +sequenceDiagram + participant BS as Bestelldienst + participant BD as Benutzerdienst + participant AD as Adressendienst + participant KD as Kartendienst + participant WK as Warenkorbdienst + participant ZA as Zahlungsdienst + participant VS as Versanddienst + + par Parallel: Daten abrufen + BS->>BD: Kundendaten abrufen + BD-->>BS: Kunde + and + BS->>AD: Adresse abrufen + AD-->>BS: Rechnungsadresse + and + BS->>KD: Karte abrufen + KD-->>BS: Zahlungskarte + and + BS->>WK: Warenkorbartikel abrufen + WK-->>BS: Artikelliste + end + BS->>ZA: Zahlung autorisieren + ZA-->>BS: Autorisierungsantwort + alt Zahlung abgelehnt + BS-->>BS: Bestellung ablehnen + else Zahlung genehmigt + BS->>VS: Lieferung beauftragen + VS-->>BS: Lieferungsbestätigung + BS->>BS: Bestellung speichern + end +``` + +Das Verhalten ist in der Code-Logik des Bestelldienstes fest eingebrannt – es handelt sich um fest programmierte Aufrufreihenfolgen mit einem konfigurierbaren Timeout von 5 Sekunden [9]. Eine Änderung der Abfolge oder der beteiligten Dienste wäre eine Entwicklungsaufgabe, keine Konfigurationsänderung. diff --git a/backend/internal/ask/testdata/diagrams/eval-order-services-de.txt b/backend/internal/ask/testdata/diagrams/eval-order-services-de.txt new file mode 100644 index 00000000..67f129e3 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-order-services-de.txt @@ -0,0 +1,47 @@ +## Dienste, die der Bestelldienst vor dem Speichern abfragt + +Der Bestelldienst ruft vor dem Speichern einer Bestellung insgesamt **sechs Dienste** auf: vier parallel zu Beginn (Benutzer-, Adressen-, Karten- und Warenkorbdienst), dann den Zahlungsdienst zur Autorisierung und bei Erfolg den Versanddienst zur Erstellung einer Lieferung [1][2]. + +Zuerst startet der Bestelldienst vier parallele asynchrone HTTP-GET-Aufrufe. Über die in der Bestellanfrage übergebenen URLs fragt er den **Benutzerdienst** nach den Kundendaten, den **Adressendienst** nach der Rechnungsadresse, den **Kartendienst** nach den Zahlungskartendetails und den **Warenkorbdienst** nach der Artikelliste samt Stückpreisen ab [2]. Die Adress-URL zeigt auf den Benutzerdienst (`http://user.<domain>/addresses/…`) [3]. + +Sobald die vier Antworten vorliegen, berechnet der Bestelldienst den Gesamtbetrag – Artikelpreise zzgl. pauschaler Versandkosten in Höhe von 4,99 Einheiten [4] – und sendet diesen zusammen mit den Adress-, Karten- und Kundendaten an den **Zahlungsdienst** per HTTP-POST (Konfigurationspfad: `payment`) [1][2][5]. Der Zahlungsdienst prüft, ob der Betrag autorisiert werden kann [6]. Ist die Zahlung nicht autorisiert oder die Antwort nicht lesbar, wird die Bestellung mit einer entsprechenden Meldung abgelehnt [1]. + +Erst nach erfolgreicher Zahlungsautorisation erstellt der Bestelldienst einen neuen Versandauftrag über den **Versanddienst** per HTTP-POST (Konfigurationspfad: `shipping`) [1][7]. Die Kunden-ID wird aus der zuvor erhaltenen Kundenressource extrahiert [8]. Erst danach werden alle gesammelten Daten – Kunde, Adresse, Karte, Artikel, Lieferung, Datum und Gesamtbetrag – zu einer Bestellung zusammengefügt und in der Datenbank gespeichert [1]. + +Die nachfolgende Abbildung zeigt den Ablauf inklusive der Verzweigung bei nicht autorisierter Zahlung: + +```mermaid +sequenceDiagram + participant BS as Bestelldienst + participant BD as Benutzerdienst + participant AD as Adressendienst + participant KD as Kartendienst + participant WK as Warenkorbdienst + participant ZA as Zahlungsdienst + participant VS as Versanddienst + + par Parallel: Daten abrufen + BS->>BD: Kundendaten abrufen + BD-->>BS: Kunde + and + BS->>AD: Adresse abrufen + AD-->>BS: Rechnungsadresse + and + BS->>KD: Karte abrufen + KD-->>BS: Zahlungskarte + and + BS->>WK: Warenkorbartikel abrufen + WK-->>BS: Artikelliste + end + BS->>ZA: Zahlung autorisieren + ZA-->>BS: Autorisierungsantwort + alt Zahlung abgelehnt + BS-->>BS: Bestellung ablehnen + else Zahlung genehmigt + BS->>VS: Lieferung beauftragen + VS-->>BS: Lieferungsbestätigung + BS->>BS: Bestellung speichern + end +``` + +Das Verhalten ist in der Code-Logik des Bestelldienstes fest eingebrannt – es handelt sich um fest programmierte Aufrufreihenfolgen mit einem konfigurierbaren Timeout von 5 Sekunden [9]. Eine Änderung der Abfolge oder der beteiligten Dienste wäre eine Entwicklungsaufgabe, keine Konfigurationsänderung. diff --git a/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.golden b/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.golden new file mode 100644 index 00000000..c0b28ff7 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.golden @@ -0,0 +1,28 @@ +Wenn eine Zahlung abgelehnt wird, wird die Bestellung nicht aufgegeben und der Kunde erhält eine Fehlermeldung mit dem Ablehnungsgrund [1][2]. + +Der Bestellvorgang startet, wenn der Kunde eine Bestellung aufgibt. Das System berechnet den Gesamtbetrag aus den Artikelpreisen und den Versandkosten [3]. Anschliessend wird eine Autorisierungsanfrage an den Zahlungsdienst mit diesem Gesamtbetrag gesendet [1]. Der Zahlungsdienst prüft, ob der Betrag gültig ist und ob er einen konfigurierten Schwellenwert überschreitet. Wird der Betrag als ungültig (z.B. negativ) erachtet oder überschreitet er den erlaubten Höchstbetrag, lehnt der Dienst die Zahlung ab und gibt eine entsprechende Nachricht zurück [4]. Die Ablehnungsmeldung wird beispielsweise lauten: „Payment declined: amount exceeds X.XX“, wobei X.XX der konfigurierte Höchstbetrag ist [4]. + +Die Antwort des Zahlungsdienstes wird im Auftragssystem ausgewertet. Wenn die Antwort nicht vorhanden ist oder das Feld „autorisiert“ auf „false“ steht, wird eine Ausnahme vom Typ `PaymentDeclinedException` ausgelöst [1]. Diese Ausnahme bewirkt, dass der HTTP-Statuscode 406 (Nicht akzeptabel) zurückgegeben wird [5][6]. + +Im Frontend wird bei Erhalt des Statuscodes 406 eine Fehlermeldung auf der Benutzeroberfläche eingeblendet. Diese Meldung enthält den Text, der vom Zahlungsdienst als Ablehnungsgrund übermittelt wurde (z.B. „Payment declined: amount exceeds 100.00“) [2]. Der Warenkorb des Kunden bleibt erhalten, sodass er die Bestellung nach Überprüfung der Daten erneut versuchen kann. + +Der gesamte Ablauft entspricht einem festgelegten Prozess in der Software, der keine manuelle Eingriff erfordert. Der konkrete Schwellenwert, ab dem Zahlungen abgelehnt werden, ist eine konfigurierbare Einstellung im Zahlungsdienst [4][7]. + +```mermaid +flowchart TD + a["Bestellung aufgeben"] + b{"Betrag gültig?"} + c{"Schwellenwert überschritten?"} + d["Zahlung ablehnen"] + e["Zahlung autorisieren"] + f["Bestellung bestätigen"] + g["Fehlermeldung anzeigen"] + + a --> b + b -- Ja --> c + b -- Nein --> d + c -- Ja --> d + c -- Nein --> e + e --> f + d --> g +``` diff --git a/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.txt b/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.txt new file mode 100644 index 00000000..c0b28ff7 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-payment-declined-de.txt @@ -0,0 +1,28 @@ +Wenn eine Zahlung abgelehnt wird, wird die Bestellung nicht aufgegeben und der Kunde erhält eine Fehlermeldung mit dem Ablehnungsgrund [1][2]. + +Der Bestellvorgang startet, wenn der Kunde eine Bestellung aufgibt. Das System berechnet den Gesamtbetrag aus den Artikelpreisen und den Versandkosten [3]. Anschliessend wird eine Autorisierungsanfrage an den Zahlungsdienst mit diesem Gesamtbetrag gesendet [1]. Der Zahlungsdienst prüft, ob der Betrag gültig ist und ob er einen konfigurierten Schwellenwert überschreitet. Wird der Betrag als ungültig (z.B. negativ) erachtet oder überschreitet er den erlaubten Höchstbetrag, lehnt der Dienst die Zahlung ab und gibt eine entsprechende Nachricht zurück [4]. Die Ablehnungsmeldung wird beispielsweise lauten: „Payment declined: amount exceeds X.XX“, wobei X.XX der konfigurierte Höchstbetrag ist [4]. + +Die Antwort des Zahlungsdienstes wird im Auftragssystem ausgewertet. Wenn die Antwort nicht vorhanden ist oder das Feld „autorisiert“ auf „false“ steht, wird eine Ausnahme vom Typ `PaymentDeclinedException` ausgelöst [1]. Diese Ausnahme bewirkt, dass der HTTP-Statuscode 406 (Nicht akzeptabel) zurückgegeben wird [5][6]. + +Im Frontend wird bei Erhalt des Statuscodes 406 eine Fehlermeldung auf der Benutzeroberfläche eingeblendet. Diese Meldung enthält den Text, der vom Zahlungsdienst als Ablehnungsgrund übermittelt wurde (z.B. „Payment declined: amount exceeds 100.00“) [2]. Der Warenkorb des Kunden bleibt erhalten, sodass er die Bestellung nach Überprüfung der Daten erneut versuchen kann. + +Der gesamte Ablauft entspricht einem festgelegten Prozess in der Software, der keine manuelle Eingriff erfordert. Der konkrete Schwellenwert, ab dem Zahlungen abgelehnt werden, ist eine konfigurierbare Einstellung im Zahlungsdienst [4][7]. + +```mermaid +flowchart TD + a["Bestellung aufgeben"] + b{"Betrag gültig?"} + c{"Schwellenwert überschritten?"} + d["Zahlung ablehnen"] + e["Zahlung autorisieren"] + f["Bestellung bestätigen"] + g["Fehlermeldung anzeigen"] + + a --> b + b -- Ja --> c + b -- Nein --> d + c -- Ja --> d + c -- Nein --> e + e --> f + d --> g +``` diff --git a/backend/internal/ask/testdata/diagrams/eval-queue-down-en.golden b/backend/internal/ask/testdata/diagrams/eval-queue-down-en.golden new file mode 100644 index 00000000..e255f6c6 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-queue-down-en.golden @@ -0,0 +1,14 @@ +The shipment is accepted and returned to the caller, but it is not queued for processing [1]. + +When a new shipment is submitted, the shipping service immediately attempts to place it on a message queue for asynchronous handling. If that operation succeeds, the shipment is sent to the queue and will be processed later by a queue-master service [1][2]. However, if the message queue is unavailable, the attempt fails and throws an exception. The service is designed to catch this exception, log a message about the failure, and then return the original shipment object directly to the caller without interruption [1]. + +This behavior is hardcoded in the shipping service; it is not a configurable setting. The code explicitly accepts the shipment on queue failure to ensure the overall request does not crash, returning the shipment as if it were successful [1]. A corresponding test verifies that this resilience works: when the queue is mocked to throw an error, the service still returns the original shipment and does not fail [3]. + +The design is a defensive measure to maintain system availability during downstream queue failures, but the code itself warns that this approach—silently accepting data without queuing—is not suitable for a real production environment [1]. The shipment record exists locally, but any further processing steps that depend on the queue (like spawning a container) will not occur [2][4]. + +```mermaid +flowchart TD + A["Receive shipment request"] --> B{"Attempt to send to queue"} + B -- "Success" --> C["Return shipment (queued)"] + B -- "Exception" --> D["Log error and return shipment (not queued)"] +``` diff --git a/backend/internal/ask/testdata/diagrams/eval-queue-down-en.txt b/backend/internal/ask/testdata/diagrams/eval-queue-down-en.txt new file mode 100644 index 00000000..e255f6c6 --- /dev/null +++ b/backend/internal/ask/testdata/diagrams/eval-queue-down-en.txt @@ -0,0 +1,14 @@ +The shipment is accepted and returned to the caller, but it is not queued for processing [1]. + +When a new shipment is submitted, the shipping service immediately attempts to place it on a message queue for asynchronous handling. If that operation succeeds, the shipment is sent to the queue and will be processed later by a queue-master service [1][2]. However, if the message queue is unavailable, the attempt fails and throws an exception. The service is designed to catch this exception, log a message about the failure, and then return the original shipment object directly to the caller without interruption [1]. + +This behavior is hardcoded in the shipping service; it is not a configurable setting. The code explicitly accepts the shipment on queue failure to ensure the overall request does not crash, returning the shipment as if it were successful [1]. A corresponding test verifies that this resilience works: when the queue is mocked to throw an error, the service still returns the original shipment and does not fail [3]. + +The design is a defensive measure to maintain system availability during downstream queue failures, but the code itself warns that this approach—silently accepting data without queuing—is not suitable for a real production environment [1]. The shipment record exists locally, but any further processing steps that depend on the queue (like spawning a container) will not occur [2][4]. + +```mermaid +flowchart TD + A["Receive shipment request"] --> B{"Attempt to send to queue"} + B -- "Success" --> C["Return shipment (queued)"] + B -- "Exception" --> D["Log error and return shipment (not queued)"] +```