@capagents/forgeqe 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ste.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * How closely a prompt follows ASD-STE100 Simplified Technical English: the share of its sentences that keep the
3
+ * writing rules that can be checked in text. The full standard also needs its approved-word dictionary, which is
4
+ * licensed; WORDS holds the common words it does not approve, each with the approved word to use instead.
5
+ *
6
+ * Rules checked, per sentence (a list item is one or more sentences):
7
+ * - length: an instruction has 20 words or fewer; a description has 25 or fewer;
8
+ * - one instruction per sentence: an instruction does not join a second one with ";" or "and then";
9
+ * - active voice: an instruction is not passive ("is sent", "are written");
10
+ * - words: no word from WORDS (e.g., "ensure", "utilize", "prior to");
11
+ * and per paragraph: 6 sentences or fewer.
12
+ *
13
+ * Words are counted as written; text in `code`, a {{placeholder}}, and a "quoted example" each count as one word,
14
+ * because each is a name the reader takes in whole.
15
+ */
16
+
17
+ /** Common words ASD-STE100 does not approve, with the approved word to use instead. */
18
+ export const WORDS: Record<string, string> = {
19
+ "additional": "more",
20
+ "approximately": "about",
21
+ "assist": "help",
22
+ "attempt": "try",
23
+ "commence": "start",
24
+ "comprise": "contain",
25
+ "e.g.": "for example",
26
+ "ensure": "make sure",
27
+ "ensures": "makes sure",
28
+ "etc.": "a full list, or \"and other\"",
29
+ "i.e.": "that is",
30
+ "in order to": "to",
31
+ "obtain": "get",
32
+ "prior to": "before",
33
+ "require": "need",
34
+ "requires": "needs",
35
+ "sufficient": "enough",
36
+ "terminate": "stop",
37
+ "utilize": "use",
38
+ "utilise": "use",
39
+ "via": "through",
40
+ "whilst": "while",
41
+ "don't": "do not",
42
+ "doesn't": "does not",
43
+ "can't": "cannot",
44
+ "won't": "will not",
45
+ "isn't": "is not",
46
+ "aren't": "are not",
47
+ "shouldn't": "do not",
48
+ "wouldn't": "would not",
49
+ "it's": "it is",
50
+ }
51
+
52
+ /** Verbs a sentence starts with when it is an instruction (after a leading "If …," or "When …," clause). */
53
+ const VERBS = new Set(
54
+ (
55
+ "accept act add answer append apply ask assert attach avoid build call change check choose cite click close compare confirm copy count cover create " +
56
+ "decide delete derive describe dismiss do edit end explain fill find finish fix flag focus follow generate get give group include insert install " +
57
+ "join keep leave let limit link list load look make map mark mention merge move name navigate note open pass pick plan point post prefer press put " +
58
+ "quote raise read record refer remove rename reply report rerun resolve respond retry return review run save say scroll select send set show skip " +
59
+ "sort split start state stay stop summarise summarize take tell test treat trust try type update use verify wait work write never always"
60
+ ).split(" "),
61
+ )
62
+
63
+ const LEAD = /^(if|when|whenever|before|after|unless|while|once|until|for|in|on|with|without|where|to)\b[^,]{0,140},\s+/i
64
+ const PASSIVE = /\b(is|are|was|were|be|been|being)\s+(\w+ly\s+)?(\w+ed|written|sent|given|made|done|shown|taken|known|seen|found|run|built|kept|left|set|put|read|chosen|broken|held)\b/i
65
+
66
+ export type SentenceIssue = "length" | "two instructions" | "passive" | "word"
67
+
68
+ export type SteSentence = { text: string; words: number; instruction: boolean; issues: { rule: SentenceIssue; detail: string }[] }
69
+
70
+ export type SteScore = {
71
+ /** Sentences that keep every rule, of all sentences, 0 to 1. */
72
+ score: number
73
+ sentences: number
74
+ compliant: number
75
+ /** Paragraphs with more than 6 sentences. */
76
+ longParagraphs: number
77
+ issues: SteSentence[]
78
+ }
79
+
80
+ /** Text a reader takes in as one word: `code`, {{placeholders}}, and "quoted examples". */
81
+ function collapse(text: string): string {
82
+ return text
83
+ .replace(/`[^`]*`/g, "CODE")
84
+ .replace(/\{\{[^}]*\}\}/g, "NAME")
85
+ .replace(/"[^"\n]{1,200}"/g, "QUOTE")
86
+ .replace(/“[^”\n]{1,200}”/g, "QUOTE")
87
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
88
+ }
89
+
90
+ export function countWords(text: string): number {
91
+ return (collapse(text).match(/[A-Za-z0-9][\w'’./-]*/g) || []).length
92
+ }
93
+
94
+ export function isInstruction(sentence: string): boolean {
95
+ const s = collapse(sentence).replace(/^[\s>*_-]+/, "").replace(LEAD, "")
96
+ const first = (s.match(/^[A-Za-z']+/)?.[0] || "").toLowerCase()
97
+ return VERBS.has(first)
98
+ }
99
+
100
+ /** Sentences of a prompt, by paragraph: list items, and sentences inside them, each one; code blocks and headings left out. */
101
+ const ITEM_MARK = "\u0001"
102
+
103
+ export function steParagraphs(markdown: string): string[][] {
104
+ const paragraphs: string[][] = []
105
+ let fence = false
106
+ let current: string[] = []
107
+ const end = () => {
108
+ if (current.length) paragraphs.push(current)
109
+ current = []
110
+ }
111
+ const blocks: string[] = []
112
+ for (const raw of markdown.replace(/\r/g, "").split("\n")) {
113
+ if (/^\s*(```|~~~)/.test(raw)) {
114
+ fence = !fence
115
+ continue
116
+ }
117
+ if (fence) continue
118
+ const line = raw.trimEnd()
119
+ if (!line.trim() || /^#{1,6}\s/.test(line) || /^\s*\|/.test(line) || /^\{\{>/.test(line.trim())) {
120
+ if (blocks.length) blocks.push("\n")
121
+ blocks.push("\n")
122
+ continue
123
+ }
124
+ if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) blocks.push("\n", ITEM_MARK + line.replace(/^\s*([-*+]|\d+[.)])\s+/, ""))
125
+ else blocks.push(` ${line.trim()}`)
126
+ }
127
+ // A blank line ends a paragraph. A list is not a paragraph: each item is its own, so only prose counts toward 6.
128
+ for (const part of blocks.join("").split(/\n\n+/)) {
129
+ for (const item of part.split("\n")) {
130
+ if (item.startsWith(ITEM_MARK)) {
131
+ end()
132
+ current = splitSentences(item.slice(ITEM_MARK.length))
133
+ end()
134
+ } else for (const sentence of splitSentences(item)) current.push(sentence)
135
+ }
136
+ end()
137
+ }
138
+ end()
139
+ return paragraphs.filter((p) => p.length)
140
+ }
141
+
142
+ function splitSentences(text: string): string[] {
143
+ const t = text.trim()
144
+ if (!t) return []
145
+ // Protect what has a full stop inside: abbreviations, versions, file names, and quoted text.
146
+ const masked = t.replace(/"[^"]*"|`[^`]*`|\b(e\.g|i\.e|etc|vs|approx)\.|\w\.\w/g, (m) => m.replace(/[.!?]/g, "\u0000"))
147
+ return masked
148
+ .split(/(?<=[.!?])\s+(?=[A-Z"`(*])/)
149
+ .map((s) => s.replace(/\u0000/g, ".").trim())
150
+ .filter((s) => /[A-Za-z]/.test(s))
151
+ }
152
+
153
+ export function checkSentence(text: string): SteSentence {
154
+ const words = countWords(text)
155
+ const instruction = isInstruction(text)
156
+ const issues: SteSentence["issues"] = []
157
+ const limit = instruction ? 20 : 25
158
+ if (words > limit) issues.push({ rule: "length", detail: `${words} words; ${instruction ? "an instruction" : "a description"} has ${limit} or fewer` })
159
+ const plain = collapse(text)
160
+ if (instruction && (/;\s+\w/.test(plain) || /,?\s+and then\s+/i.test(plain))) issues.push({ rule: "two instructions", detail: "one instruction per sentence" })
161
+ if (instruction && PASSIVE.test(plain)) issues.push({ rule: "passive", detail: `"${plain.match(PASSIVE)![0]}": use the active voice` })
162
+ const lower = ` ${plain.toLowerCase()} `
163
+ for (const [word, use] of Object.entries(WORDS)) {
164
+ const at = lower.search(new RegExp(`[^a-z]${word.replace(/[.]/g, "\\.")}[^a-z]`))
165
+ if (at >= 0) issues.push({ rule: "word", detail: `"${word}": use ${use}` })
166
+ }
167
+ return { text, words, instruction, issues }
168
+ }
169
+
170
+ export function steScore(markdown: string): SteScore {
171
+ const paragraphs = steParagraphs(markdown)
172
+ const all = paragraphs.flat().map(checkSentence)
173
+ const compliant = all.filter((s) => !s.issues.length).length
174
+ return {
175
+ score: all.length ? compliant / all.length : 1,
176
+ sentences: all.length,
177
+ compliant,
178
+ longParagraphs: paragraphs.filter((p) => p.length > 6).length,
179
+ issues: all.filter((s) => s.issues.length),
180
+ }
181
+ }
182
+
183
+ /** The share of sentences every built-in prompt keeps to; a project's own prompts are scored against it too. */
184
+ export const STE_TARGET = 0.8
@@ -49,7 +49,20 @@ export function RefinePanel(props: { state: RefineState; width: number; onChoose
49
49
  <text fg={theme.err}>{state.message}</text>
50
50
  ) : (
51
51
  <>
52
- <text fg={theme.text}>{state.result.refined}</text>
52
+ {/* The request as it is sent: its labelled parts (Goal, Context, Steps, Rules, Result) stand out. */}
53
+ {state.result.refined.split("\n").map((line, i) => {
54
+ const label = /^(Goal|Context|Steps|Rules|Result):/.exec(line)
55
+ return label ? (
56
+ <text key={i}>
57
+ <span fg={COLOR}>{label[0]}</span>
58
+ <span fg={theme.text}>{line.slice(label[0].length)}</span>
59
+ </text>
60
+ ) : (
61
+ <text key={i} fg={line.trim() ? theme.text : undefined}>
62
+ {line || " "}
63
+ </text>
64
+ )
65
+ })}
53
66
  {section("Checked", theme.ok, "✓", state.result.grounded.filter((g) => g.checked).map((g) => g.fact))}
54
67
  {section("Assumed", theme.warn, "~", [...state.result.grounded.filter((g) => !g.checked).map((g) => g.fact), ...state.result.assumed])}
55
68
  {section("Missing — answer these in your request", theme.err, "?", state.result.missing)}