@rubytech/create-maxy-code 0.1.265 → 0.1.266
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/package.json +1 -1
- package/payload/platform/lib/models/dist/index.d.ts +1 -1
- package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/models/dist/index.js +5 -2
- package/payload/platform/lib/models/dist/index.js.map +1 -1
- package/payload/platform/lib/models/src/index.ts +5 -2
- package/payload/platform/neo4j/schema.cypher +13 -0
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js +9 -9
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js.map +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -3
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
- package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -5
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +9 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +59 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/templates/account.json +1 -1
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/platform/templates/specialists/agents/librarian.md +1 -1
- package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
- package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
- package/payload/premium-plugins/writer-craft/PLUGIN.md +4 -4
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +44 -9
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +31 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +28 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +93 -44
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +22 -8
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +93 -84
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +18 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +32 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +35 -2
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +52 -10
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +39 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +108 -44
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +1 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +24 -4
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +136 -102
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +45 -3
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +34 -23
- package/payload/platform/plugins/business-assistant/references/quote-engine.md +0 -122
- package/payload/platform/plugins/business-assistant/references/quote-generation.md +0 -94
- package/payload/platform/plugins/business-assistant/references/quoting.md +0 -85
- package/payload/platform/plugins/business-assistant/skills/pricing-method/SKILL.md +0 -78
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/learning-from-history.md +0 -51
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/maintenance.md +0 -32
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/manual-definition.md +0 -42
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/verification.md +0 -37
|
@@ -30,9 +30,11 @@ import neo4j, { type Session } from "neo4j-driver";
|
|
|
30
30
|
import { getSession } from "../lib/neo4j.js";
|
|
31
31
|
import { notTrashed } from "../lib/voice-corpus.js";
|
|
32
32
|
import {
|
|
33
|
-
VOICE_CORPUS_WHERE,
|
|
34
33
|
voiceCorpusWhereWithFormat,
|
|
34
|
+
voiceCorpusWhereWithFormatAndAuthor,
|
|
35
|
+
ORG_USER_ID,
|
|
35
36
|
type VoiceFormat,
|
|
37
|
+
type VoiceScope,
|
|
36
38
|
} from "../lib/voice-corpus.js";
|
|
37
39
|
|
|
38
40
|
const DEFAULT_TOKEN_BUDGET_SHORT = 16_000;
|
|
@@ -56,6 +58,13 @@ export interface VoiceRetrieveConditioningParams {
|
|
|
56
58
|
*/
|
|
57
59
|
length?: "short" | "long";
|
|
58
60
|
register?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Voice scope (Task 676). `'personal'` (default) retrieves the caller's own
|
|
63
|
+
* profile and author-filtered exemplars, falling back to the org profile
|
|
64
|
+
* when the caller has none. `'org'` retrieves the account/house profile and
|
|
65
|
+
* account-wide exemplars, degrading to the default register when absent.
|
|
66
|
+
*/
|
|
67
|
+
scope?: VoiceScope;
|
|
59
68
|
};
|
|
60
69
|
tokenBudget?: number;
|
|
61
70
|
}
|
|
@@ -71,14 +80,21 @@ export interface VoiceExemplar {
|
|
|
71
80
|
* Outcome discriminator (Task 493). Three mutually-exclusive states so the
|
|
72
81
|
* drafting agent and operator can tell genuine no-data apart from a failed
|
|
73
82
|
* lookup — both previously collapsed into the same empty payload:
|
|
74
|
-
* - "ok"
|
|
75
|
-
*
|
|
76
|
-
* - "
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
83
|
+
* - "ok" — query ran; a style card and/or exemplars were returned
|
|
84
|
+
* for the requested scope.
|
|
85
|
+
* - "fallback-org"— a personal request found no personal profile/corpus, so
|
|
86
|
+
* the org profile was returned instead (Task 676). Drafting
|
|
87
|
+
* uses the org voice; this is the standing signal that the
|
|
88
|
+
* caller has no personal attribution yet.
|
|
89
|
+
* - "no-data" — query ran; no profile exists and the corpus is empty for
|
|
90
|
+
* the requested scope (and, for personal, no org fallback).
|
|
91
|
+
* - "error" — the lookup failed (blank identity, driver/connection
|
|
92
|
+
* fault, or Cypher error). `error` carries the message.
|
|
93
|
+
* Drafting degrades gracefully to default register on "no-data" and "error";
|
|
94
|
+
* "fallback-org" still injects a (org) style card. The caller must only report
|
|
95
|
+
* "no profile exists" on "no-data".
|
|
80
96
|
*/
|
|
81
|
-
export type VoiceRetrieveStatus = "ok" | "no-data" | "error";
|
|
97
|
+
export type VoiceRetrieveStatus = "ok" | "fallback-org" | "no-data" | "error";
|
|
82
98
|
|
|
83
99
|
export interface VoiceRetrieveConditioningResult {
|
|
84
100
|
styleCard: string | null;
|
|
@@ -113,39 +129,15 @@ export async function voiceRetrieveConditioning(
|
|
|
113
129
|
(length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
|
|
114
130
|
const charBudget = tokenBudget * CHARS_PER_TOKEN;
|
|
115
131
|
const format = brief.format;
|
|
116
|
-
const
|
|
132
|
+
const scope: VoiceScope = brief.scope ?? "personal";
|
|
133
|
+
const topic = (brief.topic ?? "").trim();
|
|
134
|
+
const usesTopic = topic.length > 0;
|
|
117
135
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
session = getSession();
|
|
125
|
-
// 1. Style card — per-format profile.
|
|
126
|
-
const profile = await session.run(
|
|
127
|
-
`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
|
|
128
|
-
OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
|
|
129
|
-
RETURN p.styleCard AS styleCard`,
|
|
130
|
-
{ accountId, userId, format },
|
|
131
|
-
);
|
|
132
|
-
const styleCard = (profile.records[0]?.get("styleCard") as string | null) ?? null;
|
|
133
|
-
|
|
134
|
-
// 2. Exemplars — filtered to brief.format. Keyword-aware when topic is set.
|
|
135
|
-
//
|
|
136
|
-
// Body resolution (Task 471):
|
|
137
|
-
// - For :KnowledgeDocument with HAS_SECTION children, concatenate
|
|
138
|
-
// child :Section bodies in `position` order. KD.body is null after
|
|
139
|
-
// Task 465 server-slicing.
|
|
140
|
-
// - Other labels read n.body directly via the COALESCE fallback chain.
|
|
141
|
-
//
|
|
142
|
-
// The topic filter runs against the computed body, so a topic that
|
|
143
|
-
// appears only in n.summary (LLM abstract) no longer matches — by
|
|
144
|
-
// design: the corpus surfaces verbatim prose, not the abstract.
|
|
145
|
-
const topic = (brief.topic ?? "").trim();
|
|
146
|
-
const usesTopic = topic.length > 0;
|
|
147
|
-
|
|
148
|
-
const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
|
|
136
|
+
// Body resolution (Task 471): for :KnowledgeDocument with HAS_SECTION
|
|
137
|
+
// children, concatenate child :Section bodies in `position` order (KD.body is
|
|
138
|
+
// null after Task 465 server-slicing); other labels read n.body via the
|
|
139
|
+
// COALESCE chain. The topic filter runs against the computed body.
|
|
140
|
+
const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
|
|
149
141
|
OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
|
|
150
142
|
WHERE ${notTrashed("s")}
|
|
151
143
|
WITH n, ts, s ORDER BY s.position
|
|
@@ -156,74 +148,116 @@ export async function voiceRetrieveConditioning(
|
|
|
156
148
|
ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
|
|
157
149
|
END AS body`;
|
|
158
150
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
label,
|
|
208
|
-
body: truncated,
|
|
209
|
-
source: ((r.get("source") as string | null) ?? "").slice(0, 200),
|
|
151
|
+
// getSession() is inside the try so driver-construction / connection-config
|
|
152
|
+
// faults (NEO4J_URI unset, password file missing) are caught, logged, and
|
|
153
|
+
// returned as the error state rather than escaping the function silently
|
|
154
|
+
// (Task 493).
|
|
155
|
+
let session: Session | undefined;
|
|
156
|
+
try {
|
|
157
|
+
session = getSession();
|
|
158
|
+
|
|
159
|
+
// One scoped lookup: the profile style card (keyed on the profile's userId)
|
|
160
|
+
// plus K voice-matched exemplars over the scope's corpus. Org passes the
|
|
161
|
+
// account-wide corpus + `__org__`; personal passes the author-filtered
|
|
162
|
+
// corpus + the operator's userId. Each call gets a fresh char budget.
|
|
163
|
+
const lookup = async (
|
|
164
|
+
profileUserId: string,
|
|
165
|
+
exemplarWhere: string,
|
|
166
|
+
exemplarParams: Record<string, unknown>,
|
|
167
|
+
): Promise<{ styleCard: string | null; exemplars: VoiceExemplar[] }> => {
|
|
168
|
+
const profile = await session!.run(
|
|
169
|
+
`OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
|
|
170
|
+
RETURN p.styleCard AS styleCard`,
|
|
171
|
+
{ accountId, profileUserId, format },
|
|
172
|
+
);
|
|
173
|
+
const styleCard =
|
|
174
|
+
(profile.records[0]?.get("styleCard") as string | null) ?? null;
|
|
175
|
+
|
|
176
|
+
const cypher = usesTopic
|
|
177
|
+
? `MATCH (n)
|
|
178
|
+
WHERE ${exemplarWhere}
|
|
179
|
+
${bodyComputeBlock}
|
|
180
|
+
WHERE toLower(body) CONTAINS toLower($topic)
|
|
181
|
+
RETURN elementId(n) AS id, labels(n) AS labels, body,
|
|
182
|
+
coalesce(n.subject, n.title, n.name, '') AS source, ts
|
|
183
|
+
ORDER BY ts IS NULL, ts DESC
|
|
184
|
+
LIMIT $k`
|
|
185
|
+
: `MATCH (n)
|
|
186
|
+
WHERE ${exemplarWhere}
|
|
187
|
+
${bodyComputeBlock}
|
|
188
|
+
RETURN elementId(n) AS id, labels(n) AS labels, body,
|
|
189
|
+
coalesce(n.subject, n.title, n.name, '') AS source, ts
|
|
190
|
+
ORDER BY ts IS NULL, ts DESC
|
|
191
|
+
LIMIT $k`;
|
|
192
|
+
|
|
193
|
+
const result = await session!.run(cypher, {
|
|
194
|
+
...exemplarParams,
|
|
195
|
+
topic,
|
|
196
|
+
// neo4j-driver serializes a plain JS number as a Cypher float (15 →
|
|
197
|
+
// 15.0), which LIMIT rejects. Send a Neo4j integer.
|
|
198
|
+
k: neo4j.int(k),
|
|
210
199
|
});
|
|
211
|
-
|
|
200
|
+
|
|
201
|
+
let charsUsed = 0;
|
|
202
|
+
const exemplars: VoiceExemplar[] = [];
|
|
203
|
+
for (const r of result.records) {
|
|
204
|
+
const body = (r.get("body") as string | null) ?? "";
|
|
205
|
+
if (body.length === 0) continue;
|
|
206
|
+
const labels = (r.get("labels") as string[]) ?? [];
|
|
207
|
+
const label =
|
|
208
|
+
labels.find((l) =>
|
|
209
|
+
["KnowledgeDocument", "Message", "SocialPost", "Conversation"].includes(l),
|
|
210
|
+
) ?? labels[0] ?? "Unknown";
|
|
211
|
+
const remaining = charBudget - charsUsed;
|
|
212
|
+
if (remaining <= 0) break;
|
|
213
|
+
const truncated = body.length > remaining ? body.slice(0, remaining) : body;
|
|
214
|
+
exemplars.push({
|
|
215
|
+
nodeId: r.get("id") as string,
|
|
216
|
+
label,
|
|
217
|
+
body: truncated,
|
|
218
|
+
source: ((r.get("source") as string | null) ?? "").slice(0, 200),
|
|
219
|
+
});
|
|
220
|
+
charsUsed += truncated.length;
|
|
221
|
+
}
|
|
222
|
+
return { styleCard, exemplars };
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const hasData = (r: { styleCard: string | null; exemplars: VoiceExemplar[] }) =>
|
|
226
|
+
(r.styleCard !== null && r.styleCard.length > 0) || r.exemplars.length > 0;
|
|
227
|
+
|
|
228
|
+
const orgLookup = () =>
|
|
229
|
+
lookup(ORG_USER_ID, voiceCorpusWhereWithFormat(format), { accountId, format });
|
|
230
|
+
|
|
231
|
+
let result: { styleCard: string | null; exemplars: VoiceExemplar[] };
|
|
232
|
+
let status: VoiceRetrieveStatus;
|
|
233
|
+
if (scope === "personal") {
|
|
234
|
+
const personal = await lookup(
|
|
235
|
+
userId,
|
|
236
|
+
voiceCorpusWhereWithFormatAndAuthor(format),
|
|
237
|
+
{ accountId, format, voiceAuthor: userId },
|
|
238
|
+
);
|
|
239
|
+
if (hasData(personal)) {
|
|
240
|
+
result = personal;
|
|
241
|
+
status = "ok";
|
|
242
|
+
} else {
|
|
243
|
+
// No personal profile/corpus yet — borrow the org (house) voice rather
|
|
244
|
+
// than the bare default register (Task 676).
|
|
245
|
+
const org = await orgLookup();
|
|
246
|
+
result = org;
|
|
247
|
+
status = hasData(org) ? "fallback-org" : "no-data";
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const org = await orgLookup();
|
|
251
|
+
result = org;
|
|
252
|
+
status = hasData(org) ? "ok" : "no-data";
|
|
212
253
|
}
|
|
213
254
|
|
|
214
255
|
process.stderr.write(
|
|
215
|
-
`[voice-retrieve]
|
|
216
|
-
styleCard?.length ?? 0
|
|
217
|
-
} exemplarCount=${exemplars.length} tokenBudget=${tokenBudget}\n`,
|
|
256
|
+
`[voice-retrieve] scope=${scope} format=${format} status=${status} ` +
|
|
257
|
+
`styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length} tokenBudget=${tokenBudget}\n`,
|
|
218
258
|
);
|
|
219
259
|
|
|
220
|
-
|
|
221
|
-
// card conditions nothing, so reporting "ok" would be the same lie this
|
|
222
|
-
// discriminator exists to prevent (Task 493).
|
|
223
|
-
const hasStyleCard = styleCard !== null && styleCard.length > 0;
|
|
224
|
-
const status: VoiceRetrieveStatus =
|
|
225
|
-
hasStyleCard || exemplars.length > 0 ? "ok" : "no-data";
|
|
226
|
-
return { styleCard, exemplars, status };
|
|
260
|
+
return { styleCard: result.styleCard, exemplars: result.exemplars, status };
|
|
227
261
|
} catch (err) {
|
|
228
262
|
const message = err instanceof Error ? err.message : String(err);
|
|
229
263
|
process.stderr.write(`[voice-retrieve] error: ${message}\n`);
|
|
@@ -34,12 +34,41 @@ export const AUTHORSHIP_MODES: ReadonlySet<AuthorshipMode> = new Set([
|
|
|
34
34
|
"unknown",
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the corpus author for a tag write (Task 676). An explicit, non-blank
|
|
39
|
+
* `author` wins; otherwise the tagging operator's `userId`. Throws when neither
|
|
40
|
+
* is available (no `author` argument and no `ADMIN_USER_ID` in spawn env), so a
|
|
41
|
+
* tag can never silently land with a null author.
|
|
42
|
+
*
|
|
43
|
+
* This is why single-operator accounts get personal attribution for free: with
|
|
44
|
+
* `author` omitted, the sole operator is the implicit author, so their personal
|
|
45
|
+
* corpus equals the account-wide corpus and prior behaviour is reproduced.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveVoiceAuthor(
|
|
48
|
+
author: string | undefined,
|
|
49
|
+
operatorUserId: string | null,
|
|
50
|
+
): string {
|
|
51
|
+
const explicit = (author ?? "").trim();
|
|
52
|
+
if (explicit) return explicit;
|
|
53
|
+
if (operatorUserId) return operatorUserId;
|
|
54
|
+
throw new Error(
|
|
55
|
+
"voice-tag-content: author required — no ADMIN_USER_ID in spawn env and no author given.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
export interface VoiceTagContentParams {
|
|
38
60
|
nodeIds: string[];
|
|
39
61
|
mode: AuthorshipMode;
|
|
40
62
|
accountId: string;
|
|
41
63
|
/** Writing format (required). Editorial classification — operator-supplied. */
|
|
42
64
|
format: VoiceFormat;
|
|
65
|
+
/**
|
|
66
|
+
* The corpus author (a userId) to stamp on each node (Task 676). Resolve via
|
|
67
|
+
* `resolveVoiceAuthor` before calling — explicit author, else the tagging
|
|
68
|
+
* operator. Personal distillation filters on this; org distillation ignores
|
|
69
|
+
* it (account-wide).
|
|
70
|
+
*/
|
|
71
|
+
voiceAuthor: string;
|
|
43
72
|
}
|
|
44
73
|
|
|
45
74
|
export interface VoiceTagContentResult {
|
|
@@ -51,7 +80,7 @@ export interface VoiceTagContentResult {
|
|
|
51
80
|
export async function voiceTagContent(
|
|
52
81
|
params: VoiceTagContentParams,
|
|
53
82
|
): Promise<VoiceTagContentResult> {
|
|
54
|
-
const { nodeIds, mode, accountId, format } = params;
|
|
83
|
+
const { nodeIds, mode, accountId, format, voiceAuthor } = params;
|
|
55
84
|
|
|
56
85
|
if (!AUTHORSHIP_MODES.has(mode)) {
|
|
57
86
|
throw new Error(
|
|
@@ -74,6 +103,14 @@ export async function voiceTagContent(
|
|
|
74
103
|
if (!Array.isArray(nodeIds) || nodeIds.length === 0) {
|
|
75
104
|
return { updated: 0, skipped: 0, skippedReasons: {} };
|
|
76
105
|
}
|
|
106
|
+
// voiceAuthor is checked after the empty-nodeIds early return: an empty tag is
|
|
107
|
+
// a no-op, so there is nothing to attribute. With nodes to tag, the author
|
|
108
|
+
// must be resolved (index.ts always supplies it via resolveVoiceAuthor).
|
|
109
|
+
if (!voiceAuthor) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
"voice-tag-content: voiceAuthor is required (resolve via resolveVoiceAuthor before calling).",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
77
114
|
|
|
78
115
|
const session = getSession();
|
|
79
116
|
const now = new Date().toISOString();
|
|
@@ -102,7 +139,8 @@ export async function voiceTagContent(
|
|
|
102
139
|
})
|
|
103
140
|
SET n.authorshipMode = $mode,
|
|
104
141
|
n.authorshipTaggedAt = $now,
|
|
105
|
-
n.format = $format
|
|
142
|
+
n.format = $format,
|
|
143
|
+
n.voiceAuthor = $voiceAuthor
|
|
106
144
|
RETURN elementId(n) AS id`,
|
|
107
145
|
{
|
|
108
146
|
ids: batch,
|
|
@@ -110,6 +148,7 @@ export async function voiceTagContent(
|
|
|
110
148
|
mode,
|
|
111
149
|
now,
|
|
112
150
|
format,
|
|
151
|
+
voiceAuthor,
|
|
113
152
|
primaryLabels: [...TAGGABLE_PRIMARY_LABELS],
|
|
114
153
|
},
|
|
115
154
|
);
|
|
@@ -125,8 +164,11 @@ export async function voiceTagContent(
|
|
|
125
164
|
await session.close();
|
|
126
165
|
}
|
|
127
166
|
|
|
167
|
+
// Tagging is always a personal-attribution act: every tagged node carries an
|
|
168
|
+
// author and feeds both that author's personal walk and the account-wide org
|
|
169
|
+
// walk. There is no org-only corpus node, so scope is constant `personal`.
|
|
128
170
|
process.stderr.write(
|
|
129
|
-
`[voice-tag] mode=${mode} format=${format} accountId=${accountId} updated=${updated} skipped=${
|
|
171
|
+
`[voice-tag] mode=${mode} format=${format} scope=personal author=${voiceAuthor} accountId=${accountId} updated=${updated} skipped=${
|
|
130
172
|
nodeIds.length - updated
|
|
131
173
|
}\n`,
|
|
132
174
|
);
|
|
@@ -12,8 +12,8 @@ The skill is built on five deterministic MCP tools served by the `writer-craft`
|
|
|
12
12
|
| Tool | Purpose |
|
|
13
13
|
|------|---------|
|
|
14
14
|
| `voice-tag-content` | Stamp historical or new content nodes with `authorshipMode` and operator-supplied `format`. Bulk-tags arrays of node ids. |
|
|
15
|
-
| `voice-distil-profile` | Walk the
|
|
16
|
-
| `voice-retrieve-conditioning` | Return `{styleCard, exemplars[]}` for a drafting brief. Requires `brief.format` (
|
|
15
|
+
| `voice-distil-profile` | Walk the `human-only` corpus for a given `format` and `scope` and produce a `:VoiceProfile {styleCard, generatedAt, corpusSize, feedbackEntries, format, scope}` node. `scope='personal'` (default) walks one author; `scope='org'` walks the whole account. Without a `format`, enumerates all formats present in that scope's corpus and distils each. |
|
|
16
|
+
| `voice-retrieve-conditioning` | Return `{styleCard, exemplars[], status}` for a drafting brief. Requires `brief.format`; `brief.scope` (default `personal`) picks personal vs org voice, with a personal→org fallback. K=5 for short-form, K=15 for long-form. Token-budget bounded. |
|
|
17
17
|
| `voice-record-feedback` | When the operator edits an agent draft, write a `:VoiceEdit {originalText, editedText, intent, occurredAt, format}-[:FEEDBACK_FOR]->(:VoiceProfile {format})` node. The `intent` field is a Haiku-summarised diff. |
|
|
18
18
|
| `voice-ingest-session-text` | Write operator turns from the current session as `:Message {format:'text', authorshipMode:'human-only'}` corpus nodes. The agent reads its own operator turns from context and passes them as `turns`. Deduplicates via `contentHash`. Invoked on demand when the operator asks to capture this session's voice. |
|
|
19
19
|
|
|
@@ -76,10 +76,11 @@ Query for `:KnowledgeDocument | :SocialPost | :Message` nodes belonging to this
|
|
|
76
76
|
- `1,3,7 human-only email` — tag a subset with mode + format.
|
|
77
77
|
- `2 human-led-agent-assisted note` — single-item.
|
|
78
78
|
- `5 agent-led-human-reviewed marketing-copy` — single-item.
|
|
79
|
+
- `1-10 human-only article author:joel` — attribute the batch to a named author (a `userId`) rather than the tagging operator.
|
|
79
80
|
- `skip` — leave the batch as `unknown` and advance.
|
|
80
81
|
- `stop` — exit the backfill; the next session resumes from where the operator left off.
|
|
81
82
|
|
|
82
|
-
`format` is required on every command; the tool will not infer it. If the operator omits the format, ask before tagging. Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> count=<n>` per write.
|
|
83
|
+
`format` is required on every command; the tool will not infer it. If the operator omits the format, ask before tagging. `author:<userId>` is optional — **omit it and the content is attributed to the tagging operator**, so single-operator accounts get personal attribution for free and behave exactly as before. Name an author only on a multi-operator account when tagging someone else's writing. Bulk-tag via `voice-tag-content` once per operator response. Emit `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<n>` per write.
|
|
83
84
|
|
|
84
85
|
### 2b. Chat archives — paginate per conversation-document parent
|
|
85
86
|
|
|
@@ -90,7 +91,7 @@ For each conversation-document `:KnowledgeDocument` (with `conversationIdentity`
|
|
|
90
91
|
- `not me` — tag every chunk as `agent-only` (e.g. a Slack channel where the operator only forwarded other people's messages).
|
|
91
92
|
- `skip` / `stop` — same semantics as 2a.
|
|
92
93
|
|
|
93
|
-
Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains, passing `format='text'` (chat archives are unambiguously text-format). The skill emits `[voice-tag] mode=<mode> format=<format> count=<chunk-count>` once per conversation.
|
|
94
|
+
Bulk-tag via `voice-tag-content` with the list of `:Section` element IDs the conversation-document contains, passing `format='text'` (chat archives are unambiguously text-format). Attribution defaults to the tagging operator; on a multi-operator account, pass `author:<userId>` to attribute the conversation to the operator who actually owns it. The skill emits `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<chunk-count>` once per conversation.
|
|
94
95
|
|
|
95
96
|
Authorship at conversation granularity is a deliberate UX trade-off: chunks within a conversation almost always share the same operator's voice, and per-chunk tagging at archive scale would burn the operator's attention. The `mixed` opt-in covers the rare case where it matters.
|
|
96
97
|
|
|
@@ -112,7 +113,7 @@ This runs only on the admin seat (the tool is admin-allowlisted). Accounts witho
|
|
|
112
113
|
|
|
113
114
|
## Flow — drafting-time conditioning
|
|
114
115
|
|
|
115
|
-
Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register}` — where `format` is the target corpus format and returns:
|
|
116
|
+
Drafting skills (email composition, Postiz, property-brochure, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure and the investor/prospectus skills request `org`. It returns:
|
|
116
117
|
|
|
117
118
|
```yaml
|
|
118
119
|
styleCard: |
|
|
@@ -132,27 +133,28 @@ exemplars:
|
|
|
132
133
|
|
|
133
134
|
Drafting skills inject both blocks into their prompt. Opt-out is per-skill: declare `voiceMirror: false` in the skill's frontmatter and the calling site skips the fetch. Default is on.
|
|
134
135
|
|
|
135
|
-
The tool returns `{styleCard, exemplars, status}` with a discriminated `status` (Task 493):
|
|
136
|
+
The tool returns `{styleCard, exemplars, status}` with a discriminated `status` (Task 493, extended Task 676):
|
|
136
137
|
|
|
137
|
-
- **`ok`** — a profile and/or exemplars were returned; inject both blocks.
|
|
138
|
-
- **`
|
|
138
|
+
- **`ok`** — a profile and/or exemplars were returned for the requested scope; inject both blocks.
|
|
139
|
+
- **`fallback-org`** — a `personal` request found no personal profile/corpus, so the **org (house) voice** was returned instead. Inject the (org) blocks and draft normally. This is the standing signal that the calling operator has no personal attribution yet — the account is running on the house voice. Don't tell the operator they have "no profile"; they have the house one.
|
|
140
|
+
- **`no-data`** — the lookup ran but no `:VoiceProfile` exists for this scope and the corpus is empty (and, for a personal request, no org profile either). Fall back to default register; this is the only state where you say "you have no voice profile yet".
|
|
139
141
|
- **`error`** — the lookup failed (the payload carries an `error` message). Fall back to default register *exactly as for no-data* so drafting never crashes — but **never** tell the operator they have no profile; the profile state is unknown because the lookup errored.
|
|
140
142
|
|
|
141
|
-
In all
|
|
143
|
+
In all four states drafting proceeds. `ok` and `fallback-org` inject a style card; `no-data` and `error` degrade to the default register. The distinction matters only for what you tell the operator about *why* a given voice was (or was not) applied.
|
|
142
144
|
|
|
143
|
-
Emit `[voice-retrieve]
|
|
145
|
+
Emit `[voice-retrieve] scope=<scope> format=<format> status=<status> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` per call.
|
|
144
146
|
|
|
145
147
|
## Flow — feedback capture
|
|
146
148
|
|
|
147
149
|
When the operator edits an agent draft (in the email composer's edit loop, or in any drafting skill that supports inline edits), capture the diff:
|
|
148
150
|
|
|
149
|
-
1. The calling skill passes `{originalText, editedText, format}` to `voice-record-feedback`.
|
|
151
|
+
1. The calling skill passes `{originalText, editedText, format, scope}` to `voice-record-feedback`. `scope` matches the scope the draft was retrieved at — an edit on an org draft passes `scope: "org"`.
|
|
150
152
|
2. The tool runs a Haiku diff summarisation to produce an `intent` field — a one-sentence description of what the operator changed and why. Example intents: "shorter and dropped the apology", "switched to first person", "removed sign-off", "added a specific date".
|
|
151
|
-
3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, userId, accountId, format})-[:FEEDBACK_FOR]->(:VoiceProfile {format})` for the operator's
|
|
153
|
+
3. The tool writes `(:VoiceEdit {originalText, editedText, intent, occurredAt, userId, scope, accountId, format})-[:FEEDBACK_FOR]->(:VoiceProfile {format})` for the scoped profile. An org edit routes to the org profile (`userId='__org__'`); a personal edit to the operator's own. The real editor is always preserved via `(:AdminUser)-[:AUTHORED]->(:VoiceEdit)`, even on org edits.
|
|
152
154
|
|
|
153
|
-
Feedback accumulates between distillations. The next `voice-distil-profile` run reads every `:VoiceEdit` linked to
|
|
155
|
+
Feedback accumulates between distillations. The next `voice-distil-profile` run at the matching scope reads every `:VoiceEdit` linked to that profile and feeds the intents into the style-card refinement.
|
|
154
156
|
|
|
155
|
-
Emit `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>`.
|
|
157
|
+
Emit `[voice-record-feedback] scope=<scope> userId=<id|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>`.
|
|
156
158
|
|
|
157
159
|
## Distillation cadence
|
|
158
160
|
|
|
@@ -184,21 +186,30 @@ When `eligible=0` (every named document failed the predicate) surface the `ineli
|
|
|
184
186
|
|
|
185
187
|
Amend mode does not apply to the on-demand session-text capture path. Session-text ingest stays on the full-corpus path; amend is operator-initiated only.
|
|
186
188
|
|
|
187
|
-
##
|
|
189
|
+
## Org and personal scope
|
|
188
190
|
|
|
189
|
-
|
|
191
|
+
Every account holds, per format, **one org (house) profile and any number of personal profiles** at once (Task 676):
|
|
192
|
+
|
|
193
|
+
- **Personal** — one operator's own voice. Key `(accountId, userId, format)`, `scope='personal'`, anchored on the operator's `:AdminUser`. Distilled from that operator's author-tagged content only (`n.voiceAuthor = userId`).
|
|
194
|
+
- **Org** — the account/house voice. Key `(accountId, '__org__', format)`, `scope='org'`, anchored on the account's `:LocalBusiness`. Distilled from the **whole account** (every author's content), not one person's.
|
|
195
|
+
|
|
196
|
+
`__org__` is a reserved sentinel userId. It keeps the existing `(accountId, userId, format)` unique constraint valid with no migration — exactly one org profile per `(account, format)` — and a real operator must never hold it.
|
|
197
|
+
|
|
198
|
+
Which voice a draft uses is the `scope` on the retrieval brief: `"my voice"` → personal, `"the house/org voice"` → org. A personal request with no personal profile yet falls back to the org profile (`status: "fallback-org"`); an org request with no org profile degrades to the default register (`status: "no-data"`).
|
|
199
|
+
|
|
200
|
+
**Single-operator accounts are unchanged.** With author omitted at tag time, the sole operator is the implicit author, so their personal corpus equals the account-wide corpus and personal distillation reproduces the prior single profile. Legacy content tagged before Task 676 carries no `voiceAuthor`; it appears only in the account-wide org walk until re-tagged, and personal drafts fall back to the org voice in the meantime (legacy `voiceAuthor` backfill is Task 678; author inference from source metadata is Task 679).
|
|
190
201
|
|
|
191
202
|
## Observability
|
|
192
203
|
|
|
193
204
|
| Tag | When |
|
|
194
205
|
|-----|------|
|
|
195
|
-
| `[voice-tag] mode=<mode> format=<format>
|
|
196
|
-
| `[voice-distil] userId=<id> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation. |
|
|
197
|
-
| `[voice-distil] skip reason=<below-threshold\|recent
|
|
198
|
-
| `[voice-distil] mode=amend userId=<id> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
|
|
199
|
-
| `[voice-distil] mode=write userId=<id> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
|
|
200
|
-
| `[voice-retrieve]
|
|
201
|
-
| `[voice-record-feedback] userId=<id> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
|
|
206
|
+
| `[voice-tag] mode=<mode> format=<format> scope=personal author=<userId> count=<n>` | Every tag write. `author=` is the resolved value (operator's id when omitted). |
|
|
207
|
+
| `[voice-distil] scope=<scope> userId=<id\|__org__> anchor=<AdminUser\|LocalBusiness> format=<format> corpusSize=<n> generatedAt=<iso> feedbackEntries=<n>` | Each distillation write. Org also carries `sentinel=__org__`. |
|
|
208
|
+
| `[voice-distil] scope=<scope> userId=<id\|__org__> format=<format> skip reason=<below-threshold\|recent\|empty-corpus>` | When the cadence/empty guard fires. |
|
|
209
|
+
| `[voice-distil] scope=<scope> mode=amend userId=<id\|__org__> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
|
|
210
|
+
| `[voice-distil] scope=<scope> mode=write userId=<id\|__org__> anchor=<…> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
|
|
211
|
+
| `[voice-retrieve] scope=<scope> format=<format> status=<ok\|fallback-org\|no-data\|error> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. `status=fallback-org` proves the personal→org fallback fired. |
|
|
212
|
+
| `[voice-record-feedback] scope=<scope> userId=<id\|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
|
|
202
213
|
| `[voice-ingest-session-text] sessionId=<id> adminUser=<id> turns=<n> bytes=<n> skipped=<m>` | On-demand session-text capture. |
|
|
203
214
|
|
|
204
215
|
**Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active format per operator. `[voice-ingest-session-text] turns≥1` appears when the operator asks to capture a session's voice.
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# Priced-quote compute engine
|
|
2
|
-
|
|
3
|
-
This reference is the **compute core** for a structured, priced quote: it turns a job (the items the
|
|
4
|
-
owner selected, with their measurements or counts) into priced figures — line prices, group
|
|
5
|
-
subtotals, and a final total — computed by **this owner's own pricing method**.
|
|
6
|
-
|
|
7
|
-
It carries no items, no units, no rates, no margins, no overheads, no taxes, and no roll-up sequence.
|
|
8
|
-
Every such value and rule is the owner's, captured as data. A trade pricing by the square metre, a
|
|
9
|
-
caterer pricing by the head, and a printer pricing by per-unit break are all priced by the same
|
|
10
|
-
approach — only their captured method differs.
|
|
11
|
-
|
|
12
|
-
The compute engine itself is **not shipped**. You generate a small bespoke engine for this owner the
|
|
13
|
-
first time they need a priced quote, and regenerate it whenever their business changes. The captured
|
|
14
|
-
method is data the engine reads. Both persist in the owner's workspace and are re-run on demand.
|
|
15
|
-
|
|
16
|
-
## When this path applies
|
|
17
|
-
|
|
18
|
-
Use this for a **priced quote computed from the owner's method** — measured or counted work that has
|
|
19
|
-
to be priced the way this owner prices it, then totalled. This is distinct from the simple
|
|
20
|
-
photo-to-quote / chat-to-quote flow in `references/quoting.md`, which formats a quote the owner has
|
|
21
|
-
already priced. If the owner has already worked out the numbers, stay in `quoting.md`. Come here when
|
|
22
|
-
the numbers have to be **computed**.
|
|
23
|
-
|
|
24
|
-
**Precondition:** a captured method for this owner must exist. Its *content* (the owner's items,
|
|
25
|
-
rules, dials, roll-up, and past-job fixtures) is produced and maintained by the method-capture flow —
|
|
26
|
-
Task 659 — which populates the method/job shape **this reference defines** below. If no captured method
|
|
27
|
-
exists, capture it first; do not invent rates or rules here.
|
|
28
|
-
|
|
29
|
-
## Where the engine and method live
|
|
30
|
-
|
|
31
|
-
The agent runs in the owner's workspace (the spawn cwd, `$ACCOUNT_DIR`). Keep the engine and the
|
|
32
|
-
captured method together under a `quoting/` subdirectory there, addressed by relative path:
|
|
33
|
-
|
|
34
|
-
- the **method** — the owner's items, dials, and roll-up, as data;
|
|
35
|
-
- the **engine** — the bespoke script that reads a method and a job and prices it;
|
|
36
|
-
- the owner's **past jobs**, each with the figures they actually issued, used to verify the engine.
|
|
37
|
-
|
|
38
|
-
These persist across sessions, are edited when the business changes, and are the engine's only inputs.
|
|
39
|
-
Nothing about the owner's business lives in this reference or in the plugin.
|
|
40
|
-
|
|
41
|
-
## The method shape (owner data)
|
|
42
|
-
|
|
43
|
-
This section is the authoritative definition of the method/job shape; the method-capture flow (Task
|
|
44
|
-
659) fills it with one owner's content. The method describes how this owner prices. For each
|
|
45
|
-
**priceable item** it records:
|
|
46
|
-
|
|
47
|
-
- what the item is (an identifier, a human label, a unit);
|
|
48
|
-
- **how a quantity arises** — the item's own rule for turning a measurement into a quantity (for
|
|
49
|
-
example, an area from supplied dimensions, a length, a volume), or that the item carries **no
|
|
50
|
-
measurement rule** because it is counted or taken as a single unit. This is the item's *default*
|
|
51
|
-
quantity rule; a quantity entered on the job line always overrides it (see the job shape);
|
|
52
|
-
- **how a line price is computed** — the owner's own rule for that item, in whatever form their
|
|
53
|
-
history shows it takes;
|
|
54
|
-
- which **group** the line rolls into (the owner's own section headings);
|
|
55
|
-
- whether the item is **standing-rule priced** (a rule fixes its price) or **judgement priced** (the
|
|
56
|
-
owner sets the price per job) — so the quote flow knows when it must ask the owner for a figure
|
|
57
|
-
rather than computing one.
|
|
58
|
-
|
|
59
|
-
Alongside the items the method holds:
|
|
60
|
-
|
|
61
|
-
- named **dials** — the owner's own scalars that their rules reference (a labour day-rate, a margin
|
|
62
|
-
multiplier, a percentage — whatever their method uses, named by them);
|
|
63
|
-
- the **roll-up** — an ordered list of steps applied above the line items to reach the final total.
|
|
64
|
-
Each step names its rule, the prior figures it reads, and the label of the figure it produces. This
|
|
65
|
-
is whatever sequence the owner actually applies. It is **not** a fixed margin-then-overhead-then-tax
|
|
66
|
-
chain: an owner may apply an overhead, a contingency, a discount, a tax, or nothing, in their own
|
|
67
|
-
order. The engine implements exactly the step kinds this owner's roll-up uses, and no others.
|
|
68
|
-
|
|
69
|
-
## The job shape (per quote)
|
|
70
|
-
|
|
71
|
-
A job is the work selected for one quote: a list of lines, each naming an item and supplying **either**
|
|
72
|
-
measurements **or** a directly-entered quantity, plus — for a judgement-priced item — the price the
|
|
73
|
-
owner set for this line. The quantity source is decided per line by precedence: a quantity entered on
|
|
74
|
-
the line is used as-is; otherwise the engine derives the quantity from the line's measurements via the
|
|
75
|
-
item's measurement rule. An item with no measurement rule therefore requires an entered quantity (or
|
|
76
|
-
its single-unit default). The job also carries the header the issued quote needs (who it is for, the
|
|
77
|
-
address, the reference).
|
|
78
|
-
|
|
79
|
-
## What the generated engine must do
|
|
80
|
-
|
|
81
|
-
The engine reads one method and one job and produces the priced result. It must:
|
|
82
|
-
|
|
83
|
-
1. **Resolve each line's quantity** — an entered quantity takes precedence; otherwise derive it from
|
|
84
|
-
the line's measurements via the item's measurement rule — and record which source was used.
|
|
85
|
-
2. **Resolve each line's price** — by the item's own rule, or by the per-line figure the owner gave for
|
|
86
|
-
a judgement item — and record which was used.
|
|
87
|
-
3. **Group** the priced lines under the owner's own headings.
|
|
88
|
-
4. **Run the roll-up** steps in their defined order to reach the final total.
|
|
89
|
-
5. **Emit observability** so a wrong figure is diagnosable from the log, not just the document: per
|
|
90
|
-
line, the resolved quantity and its source (measurement-derived vs entered) and the price rule
|
|
91
|
-
applied and its source (item rule vs per-line override); and each roll-up step with its result.
|
|
92
|
-
6. **Reconcile** the sum of the group subtotals against the running line-item total and report it as an
|
|
93
|
-
explicit pass/fail — not a silent assumption.
|
|
94
|
-
7. **Verify** on demand: given one of the owner's own past jobs, compare every computed figure
|
|
95
|
-
(line, group, final) against the figures the owner actually issued and report each as a match or
|
|
96
|
-
a mismatch.
|
|
97
|
-
|
|
98
|
-
## Expressiveness — bounded by the owner's history
|
|
99
|
-
|
|
100
|
-
Build the engine to support **exactly** the quantity, pricing, and roll-up forms the owner's own
|
|
101
|
-
history exercises — discovered when their method was captured. Do not add quantity bases, price-rule
|
|
102
|
-
forms, or roll-up step kinds the owner does not use. Unused generality is a liability: it is untested
|
|
103
|
-
against their figures and invites a wrong quote later. If a new job needs a form the method has never
|
|
104
|
-
expressed, that is a change to the captured method (and a re-verification), not a guess made here.
|
|
105
|
-
|
|
106
|
-
## Acceptance — reproduction of the owner's own figures
|
|
107
|
-
|
|
108
|
-
The engine is correct only when it **reproduces the owner's own past jobs** — line prices, group
|
|
109
|
-
subtotals, and final totals — to the owner's own precision. The fixtures are that owner's
|
|
110
|
-
history, supplied with their method; there are no built-in figures to test against. Reproducing their
|
|
111
|
-
history, not parsing cleanly, is the signal that the engine prices the way they do. Verify on every
|
|
112
|
-
generate and every method change; a reproduction failure blocks issuing a computed quote.
|
|
113
|
-
|
|
114
|
-
## Boundaries
|
|
115
|
-
|
|
116
|
-
- **Capturing and maintaining** the content of the method (items, rules, dials, roll-up, past-job
|
|
117
|
-
fixtures) is the method-capture flow — Task 659; it populates the shape this reference defines. This
|
|
118
|
-
reference consumes that method; it does not build it.
|
|
119
|
-
- **Rendering** the priced result into the documents the owner issues (internal view, client
|
|
120
|
-
breakdown, branded quote) is Task 658. Those documents are printed to PDF through the existing
|
|
121
|
-
`browser-pdf-save` path (see `references/invoicing.md`); this compute reference produces only the
|
|
122
|
-
figures, not the documents.
|