Skip to content

Commit b8abeb2

Browse files
DrBaherclaude
andcommitted
v2 #4: LLM inference from a deal description (v0.8.0)
New \`--from-deal PATH\` flag reads a free-form deal description and asks the configured T5 LLM provider to extract values for the schema's declared placeholders. The inverse of T5 detection: instead of inferring WHERE placeholders are, infer WHAT values they should take from prose deal notes. Resolution precedence updated: CLI > --params > --from-deal (LLM) > --interactive > default > error CLI / --params always win, so the user can fix or override anything the LLM got wrong without re-running inference. Locked decisions (V2_BRIEFS_REMAINING Q4.1–Q4.3): - Q4.1: same T5 provider (Anthropic / OpenAI / DRAFT_LLM_*). One network surface, one env-var set. - Q4.2: extra keys (LLM emits keys not in the placeholder list) warn to stderr; the LLM gets the allowed-key list in the prompt so this is rare in practice. - Q4.3: --from-deal does NOT require explicit --llm; inference is implicit when the flag is set. --no-llm still disables. Implementation: - New --from-deal PATH CLI flag. - inferFromDeal(dealText, placeholders, providerCfg, { fetcher }): builds a prompt with deal text + allowed keys, calls callLlm (existing helper), parses JSON response, splits values vs extraKeys vs warnings. Empty placeholder list short-circuits without an LLM call. - resolveValues gains an \`inferred\` option used between --params and --interactive precedence levels. - main() loads dealText from opts.fromDeal and threads it through the IO options bag to cmdDraft + cmdValidate. - cmdDraft + cmdValidate call inferFromDeal before resolveValues when dealText is present and --no-llm not set. Constraints documented: - --from-deal errors are fatal (EXIT.LLM) — users see bad provider configs immediately, not silently with no inferred values. - Bundle mode doesn't yet thread --from-deal per-template (deferred); shared --params JSON is the bundle-friendly structured-data path. Tests: 282 passing (269 baseline + 13 new). Coverage: inferFromDeal unit (happy path, extra keys, numeric coercion, non-string warning, missing provider, malformed LLM response, empty-placeholders short- circuit); end-to-end via main() with fakeFetcher for happy path, CLI > inferred precedence, extra-keys warning, --no-llm disables, missing file errors, missing provider errors. CHANGELOG + PARAM_SCHEMA.md §5 updated. v0.8.0. This completes the v2-remaining feature surface from V2_BRIEFS_REMAINING. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 224e79d commit b8abeb2

5 files changed

Lines changed: 465 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file. The
44
format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
55
and the project adheres to semantic versioning once it leaves 0.x.
66

7+
## 0.8.0 — 2026-05-17
8+
9+
### Added
10+
11+
- **LLM inference from a deal description** (last v2 item). New
12+
`--from-deal PATH` flag reads a free-form deal description and
13+
asks the configured T5 LLM provider to extract values for the
14+
schema's declared placeholders:
15+
```sh
16+
draft nda.md --from-deal deal-notes.txt --output draft.md
17+
```
18+
Where `deal-notes.txt` is unstructured prose:
19+
```
20+
Mutual NDA between Acme Corporation (DE) and Globex (UK), effective
21+
June 1, 2026, for a 2-year term.
22+
```
23+
The LLM is asked to fill `party_a`, `party_a_state`, `party_b`,
24+
`effective_date`, etc. — only the keys already detected as
25+
placeholders are extracted.
26+
- **Value-resolution precedence updated:**
27+
`CLI flag > --params JSON > --from-deal (LLM) > --interactive > schema default > error`.
28+
CLI / --params always win, so users can fix or override anything
29+
the LLM got wrong.
30+
- **New public API:** `inferFromDeal(dealText, placeholders, providerCfg, { fetcher })`.
31+
32+
### Decisions locked (V2_BRIEFS_REMAINING Q4.1–Q4.3)
33+
34+
- **Q4.1 Provider:** same T5 provider config — `ANTHROPIC_API_KEY`,
35+
`OPENAI_API_KEY`, or explicit `DRAFT_LLM_*`. No separate inference
36+
provider; one network surface, one set of env vars.
37+
- **Q4.2 Extra keys:** keys the LLM emits that aren't in the
38+
detected placeholders are **warned** to stderr (not dropped
39+
silently). The LLM gets a fresh list of allowed keys in the
40+
prompt so this is rare in practice.
41+
- **Q4.3 Auto-LLM:** `--from-deal` does **not** require an
42+
explicit `--llm` flag — the inference is implicit. `--no-llm`
43+
still disables it (the user can opt out of the network call).
44+
45+
### Notes
46+
47+
- `--from-deal` errors are fatal (`EXIT.LLM` for provider /
48+
network / parse failures). Users with bad provider configs see
49+
the issue immediately rather than silently running with no
50+
inferred values.
51+
- Bundle mode (v0.7.0) does not yet thread `--from-deal` through
52+
per-template inference. Deferred to a follow-up; the shared
53+
parameter resolution makes the single-doc API already useful
54+
for bundles via `--params`.
55+
756
## 0.7.0 — 2026-05-17
857

958
### Added

PARAM_SCHEMA.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,71 @@ keys and their sources.
515515
Programmatic API: `loadBundle(path)` parses + validates; `cmdBundle`
516516
runs the orchestration with the same IO contract as `cmdDraft`.
517517

518+
### LLM inference from a deal description (v0.8.0, opt-in)
519+
520+
`--from-deal PATH` reads a free-form deal description and asks the
521+
configured T5 LLM provider to extract values for the schema's
522+
declared placeholders. The inverse of T5 detection — instead of
523+
inferring *where* placeholders are in a template, infer *what
524+
values* they should take from the deal prose:
525+
526+
```sh
527+
draft nda.md --from-deal deal-notes.txt --output draft.md
528+
```
529+
530+
```
531+
# deal-notes.txt
532+
Mutual NDA between Acme Corporation (DE) and Globex (UK),
533+
effective June 1, 2026, for a 2-year term.
534+
```
535+
536+
Then `[Party A]``Acme Corporation`, `[Effective Date]`
537+
`June 1, 2026`, etc., without any `--party-a` / `--effective-date`
538+
flags.
539+
540+
**Value-resolution precedence** with `--from-deal`:
541+
542+
```
543+
CLI flag > --params JSON > --from-deal (LLM) > --interactive > schema default > error
544+
```
545+
546+
CLI / --params always win, so users can fix or override anything the
547+
LLM got wrong without re-running inference.
548+
549+
**Q4.1 locked:** same T5 provider config (`ANTHROPIC_API_KEY`,
550+
`OPENAI_API_KEY`, or explicit `DRAFT_LLM_*`). One network surface,
551+
one set of env vars.
552+
553+
**Q4.2 locked:** extra keys (LLM emits keys not in the detected
554+
placeholder list) are **warned** to stderr, not silently dropped.
555+
The LLM gets the allowed-key list in the prompt so this is rare in
556+
practice.
557+
558+
**Q4.3 locked:** `--from-deal` does **not** require explicit
559+
`--llm` — the inference is implicit when the flag is present.
560+
`--no-llm` still disables the inference call (the user can opt
561+
out of the network).
562+
563+
**Provider missing:** if no LLM provider is configured (no
564+
`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `DRAFT_LLM_*` in env),
565+
`--from-deal` errors immediately with `EXIT.LLM` (exit 5) and a
566+
clear message. Same for network / HTTP errors / non-JSON LLM
567+
responses.
568+
569+
**Resolution interaction with typed parameters:** inferred values
570+
go through the same typed-normalization step as user-supplied
571+
values. So an LLM that returns `"June 1, 2026"` for a `type: date`
572+
parameter with `format: yyyy-MM-d` gets normalized to `2026-06-1`
573+
before substitution.
574+
575+
**Bundle mode (v0.7.0) interaction:** bundles do not currently
576+
thread `--from-deal` through per-template inference. The shared
577+
parameter resolution already accepts `--params` JSON, which is the
578+
simpler structured-data path for bundle workflows. Deferred to a
579+
future release.
580+
581+
Programmatic API: `inferFromDeal(dealText, placeholders, providerCfg, { fetcher })`.
582+
518583
### Orphan handling (Q4 locked)
519584

520585
Schema declares a key whose alias list matches no detected phrase →

draft-cli.mjs

Lines changed: 149 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
7070
*/
7171

7272
/** @type {string} */
73-
export const VERSION = "0.7.0";
73+
export const VERSION = "0.8.0";
7474

7575
// ─── EXIT CODES ─────────────────────────────────────────────────────────────
7676
/**
@@ -279,6 +279,7 @@ export function parseArgs(argv) {
279279
if (a === "--params") { opts.params = argv[++i]; continue; }
280280
if (a === "--parties") { opts.parties = argv[++i]; continue; }
281281
if (a === "--bundle") { opts.bundle = argv[++i]; continue; }
282+
if (a === "--from-deal") { opts.fromDeal = argv[++i]; continue; }
282283
if (a === "--output" || a === "-o") { opts.output = argv[++i]; continue; }
283284
if (a === "--syntax") {
284285
const v = argv[++i];
@@ -808,6 +809,95 @@ ${body.slice(0, 12000)}`;
808809
return out;
809810
}
810811

812+
/**
813+
* v2 #4: LLM inference from a free-form deal description.
814+
*
815+
* Takes the prose deal description (the user's notes about parties, dates,
816+
* amounts, etc.) and asks the configured T5 LLM provider to extract values
817+
* for the placeholders the cascade has already detected. Returns
818+
* `{values, extraKeys, warnings}`:
819+
*
820+
* - values: `{key: string}` for every placeholder key the LLM filled
821+
* - extraKeys: any keys the LLM emitted that aren't in the placeholders list (Q4.2 → warn)
822+
* - warnings: human-readable messages for malformed entries
823+
*
824+
* Throws on missing provider config, missing `fetch`, network/HTTP error, or
825+
* non-JSON LLM response — same failure boundaries as `detectLlm`.
826+
*
827+
* @param {string} dealText — free-form deal description
828+
* @param {Placeholder[]} placeholders — the post-detection placeholder list
829+
* @param {ReturnType<llmProviderFromEnv>} providerCfg
830+
* @param {{ fetcher?: typeof fetch | null }} [opts]
831+
* @returns {Promise<{ values: Object<string,string>, extraKeys: string[], warnings: string[] }>}
832+
*/
833+
export async function inferFromDeal(dealText, placeholders, providerCfg, { fetcher = (typeof fetch !== "undefined" ? fetch : null) } = {}) {
834+
if (!fetcher) {
835+
const e = new Error("fetch is not available; Node 18+ is required for --from-deal");
836+
e.exitCode = EXIT.LLM;
837+
throw e;
838+
}
839+
if (!providerCfg) {
840+
const e = new Error("--from-deal requires an LLM provider; set ANTHROPIC_API_KEY / OPENAI_API_KEY / DRAFT_LLM_* in .env");
841+
e.exitCode = EXIT.LLM;
842+
throw e;
843+
}
844+
const wantedKeys = placeholders.map((p) => ({
845+
key: p.key,
846+
aliases: (p.aliases || []).slice(0, 4),
847+
first_seen_as: p.first_seen_as,
848+
}));
849+
if (wantedKeys.length === 0) {
850+
return { values: {}, extraKeys: [], warnings: [] };
851+
}
852+
const fieldList = wantedKeys.map((w) =>
853+
` - ${w.key} (template placeholder: "${w.first_seen_as}"${w.aliases.length > 1 ? `; aliases: ${w.aliases.join(", ")}` : ""})`
854+
).join("\n");
855+
const prompt = `You are filling parameters for a legal-document drafting tool.
856+
A user has written prose describing a deal. Extract values for the following
857+
fields from the deal description. Output JSON ONLY in this exact shape, with
858+
no commentary:
859+
860+
{"values":{"<key>":"<extracted_value>",...}}
861+
862+
If a field can't be confidently extracted from the description, omit it (do
863+
NOT guess). Do not invent additional fields not in the list. Match the deal's
864+
language verbatim — don't reformat dates, currencies, or names.
865+
866+
FIELDS:
867+
${fieldList}
868+
869+
DEAL DESCRIPTION:
870+
${dealText.slice(0, 12000)}`;
871+
const raw = await callLlm(providerCfg, prompt, fetcher);
872+
let parsed;
873+
try {
874+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
875+
parsed = JSON.parse(jsonMatch ? jsonMatch[0] : raw);
876+
} catch {
877+
const e = new Error(`LLM returned non-JSON response for --from-deal`);
878+
e.exitCode = EXIT.LLM;
879+
throw e;
880+
}
881+
const rawValues = (parsed && typeof parsed.values === "object" && parsed.values) ? parsed.values : {};
882+
const knownKeys = new Set(placeholders.map((p) => p.key));
883+
const values = {};
884+
const extraKeys = [];
885+
const warnings = [];
886+
for (const [k, v] of Object.entries(rawValues)) {
887+
if (!knownKeys.has(k)) {
888+
extraKeys.push(k);
889+
continue;
890+
}
891+
if (v === null || v === undefined) continue;
892+
if (typeof v !== "string" && typeof v !== "number") {
893+
warnings.push(`--from-deal: value for "${k}" was ${typeof v}, expected string; skipped`);
894+
continue;
895+
}
896+
values[k] = String(v);
897+
}
898+
return { values, extraKeys, warnings };
899+
}
900+
811901
async function callLlm(cfg, prompt, fetcher) {
812902
if (cfg.provider === "anthropic") {
813903
const r = await fetcher("https://api.anthropic.com/v1/messages", {
@@ -1460,7 +1550,7 @@ export function loadBundle(path) {
14601550
* @param {{ prompter?: (p: Placeholder) => Promise<string|null> }} [io]
14611551
* @returns {Promise<ResolvedValues>}
14621552
*/
1463-
export async function resolveValues(placeholders, opts, paramsObj, { prompter = nodePrompter } = {}) {
1553+
export async function resolveValues(placeholders, opts, paramsObj, { prompter = nodePrompter, inferred = null } = {}) {
14641554
const resolved = {};
14651555
const missing = [];
14661556
const sources = {};
@@ -1475,6 +1565,12 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
14751565
sources[p.key] = "params";
14761566
continue;
14771567
}
1568+
// v2 #4: --from-deal LLM-inferred values, between --params and --interactive.
1569+
if (inferred && Object.prototype.hasOwnProperty.call(inferred, p.key)) {
1570+
resolved[p.key] = String(inferred[p.key]);
1571+
sources[p.key] = "deal-llm";
1572+
continue;
1573+
}
14781574
if (opts.interactive) {
14791575
const v = await prompter(p);
14801576
if (v !== null && v !== undefined && v !== "") {
@@ -2089,7 +2185,7 @@ export async function cmdListPlaceholders(opts, input, schema, envObj, { fetcher
20892185
return EXIT.OK;
20902186
}
20912187

2092-
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
2188+
export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null, dealText = null } = {}) {
20932189
const result = await runCascade(input, opts, schema, envObj, { fetcher });
20942190
if (result.tier === "none") {
20952191
err.write(paint("error: no placeholders detected by any tier\n", "red", err));
@@ -2109,7 +2205,24 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
21092205
}
21102206
return EXIT.VALIDATION;
21112207
}
2112-
const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj);
2208+
// v2 #4: --from-deal LLM inference (when dealText is present and
2209+
// --no-llm not set). Provider config comes from env. Errors are fatal
2210+
// to keep the user from running with partial inferred values.
2211+
let inferred = null;
2212+
if (dealText && !opts.noLlm) {
2213+
try {
2214+
const r = await inferFromDeal(dealText, result.placeholders, llmProviderFromEnv(envObj), { fetcher });
2215+
inferred = r.values;
2216+
for (const k of r.extraKeys) {
2217+
err.write(paint(`warning: --from-deal LLM emitted unknown key "${k}" (not in template/schema)\n`, "yellow", err));
2218+
}
2219+
for (const w of r.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
2220+
} catch (e) {
2221+
err.write(paint(`error: ${e.message}\n`, "red", err));
2222+
return e.exitCode || EXIT.LLM;
2223+
}
2224+
}
2225+
const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj, { inferred });
21132226
if (missing.length > 0) {
21142227
printMissing(missing, err);
21152228
if (opts.json) {
@@ -2170,7 +2283,7 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
21702283
return EXIT.OK;
21712284
}
21722285

2173-
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null } = {}) {
2286+
export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties = null, dealText = null } = {}) {
21742287
const result = await runCascade(input, opts, schema, envObj, { fetcher });
21752288
if (result.tier === "none") {
21762289
const hasProvider = Boolean(llmProviderFromEnv(envObj));
@@ -2221,7 +2334,25 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
22212334
return EXIT.VALIDATION;
22222335
}
22232336

2224-
const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj);
2337+
// v2 #4: --from-deal LLM inference (when dealText is present and
2338+
// --no-llm not set). Provider config comes from env. Errors are fatal
2339+
// to keep the user from running with partial inferred values.
2340+
let inferred = null;
2341+
if (dealText && !opts.noLlm) {
2342+
try {
2343+
const r = await inferFromDeal(dealText, result.placeholders, llmProviderFromEnv(envObj), { fetcher });
2344+
inferred = r.values;
2345+
for (const k of r.extraKeys) {
2346+
err.write(paint(`warning: --from-deal LLM emitted unknown key "${k}" (not in template/schema)\n`, "yellow", err));
2347+
}
2348+
for (const w of r.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
2349+
} catch (e) {
2350+
err.write(paint(`error: ${e.message}\n`, "red", err));
2351+
return e.exitCode || EXIT.LLM;
2352+
}
2353+
}
2354+
2355+
const { resolved, missing, sources } = await resolveValues(result.placeholders, opts, paramsObj, { inferred });
22252356
// Footgun guard: flag --typo'd-key VALUE that didn't match any detected
22262357
// placeholder. Without this warning, a typo'd flag is silently dropped and
22272358
// the user sees only a "missing required" error without the connection.
@@ -2839,13 +2970,22 @@ export async function main(argv, io = {}) {
28392970
return EXIT.IO;
28402971
}
28412972

2842-
let input, schema, paramsObj, envObj, parties;
2973+
let input, schema, paramsObj, envObj, parties, dealText;
28432974
try {
28442975
input = await resolveInput(opts.positional[0], { spawner, stdinReader });
28452976
schema = loadSchema(input.path);
28462977
paramsObj = loadParamsFile(opts.params);
28472978
envObj = effectiveEnv(cwd, processEnv);
28482979
parties = loadParties(opts.parties || null);
2980+
// v2 #4: --from-deal PATH reads a free-form deal description.
2981+
if (opts.fromDeal) {
2982+
if (!existsSync(opts.fromDeal)) {
2983+
const e = new Error(`deal description file not found: ${opts.fromDeal}`);
2984+
e.exitCode = EXIT.IO;
2985+
throw e;
2986+
}
2987+
dealText = readFileSync(opts.fromDeal, "utf8");
2988+
}
28492989
} catch (e) {
28502990
err.write(paint(`error: ${e.message}\n`, "red", err));
28512991
return e.exitCode || EXIT.IO;
@@ -2856,9 +2996,9 @@ export async function main(argv, io = {}) {
28562996
return await cmdListPlaceholders(opts, input, schema, envObj, { fetcher, out, err });
28572997
}
28582998
if (opts.validate) {
2859-
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
2999+
return await cmdValidate(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties, dealText });
28603000
}
2861-
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties });
3001+
return await cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher, out, err, parties, dealText });
28623002
} catch (e) {
28633003
err.write(paint(`error: ${e.message}\n`, "red", err));
28643004
return e.exitCode || EXIT.IO;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@drbaher/draft-cli",
3-
"version": "0.7.0",
3+
"version": "0.8.0",
44
"description": "Fill placeholders in a legal-document template with parameter values. Part of the contract-operations suite.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)