ail: merge-prose subcommand + PROSE_ROUNDTRIP.md (iter 20d)
Closes family 20: prose-edit cycle is end-to-end. ail merge-prose <original.ail.json> <edited.prose.txt> composes a shaped LLM prompt to stdout. User pipes to their LLM (Claude/etc.), gets new .ail.json, runs ail check, saves. No built-in API client — AILang stays a compiler + tooling, the LLM mediator is supplied externally. PROSE_ROUNDTRIP.md documents the 6-step cycle, the prompt contract (preserve modes/effects/tail flags from original), failure modes, and the design choice to omit an API client. Family 20 now closes: 20a renderer + 20b polish + 20d mediator shipped; 20c rolled into 20a. Three deferred polishes (let-inlining, print sugar, doc-wrap widow control) wait for corpus signal.
This commit is contained in:
@@ -207,6 +207,111 @@ enum Cmd {
|
||||
/// surface remains form (A) (`render` / `parse`) and the canonical
|
||||
/// hashable artefact remains the JSON-AST.
|
||||
Prose { path: PathBuf },
|
||||
/// Iter 20d: composes a prompt for the prose-edit round-trip.
|
||||
///
|
||||
/// Given the original `.ail.json` and the edited `.prose.txt`,
|
||||
/// prints a prompt that asks an external LLM to emit an updated
|
||||
/// `.ail.json` integrating the prose edits. AILang ships no LLM
|
||||
/// client of its own; the user pipes the prompt to their tool of
|
||||
/// choice (Claude Code, Anthropic API, OpenAI CLI, etc.) and runs
|
||||
/// `ail check` on the result.
|
||||
///
|
||||
/// See `docs/PROSE_ROUNDTRIP.md` for the full cycle and the
|
||||
/// rationale for keeping the API client out of scope.
|
||||
MergeProse {
|
||||
/// Original `.ail.json` (carries load-bearing detail the prose elides).
|
||||
original: PathBuf,
|
||||
/// Edited `.prose.txt` (carries the human's intent).
|
||||
edited: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Composes the prose-round-trip prompt described in
|
||||
/// `docs/PROSE_ROUNDTRIP.md`.
|
||||
///
|
||||
/// The two payloads — the original `.ail.json` and the edited prose —
|
||||
/// are inserted verbatim between heredoc-style markers; the rest of
|
||||
/// the returned string is the role-statement, contract, output spec,
|
||||
/// and schema-essentials reminder that frame the LLM's task.
|
||||
///
|
||||
/// Pure: same inputs always yield the same bytes. The CLI wrapper
|
||||
/// (`Cmd::MergeProse`) is just a file-reader + `print!`.
|
||||
fn compose_merge_prose_prompt(orig_ail_json: &str, edited_prose: &str) -> String {
|
||||
// Keep this template in lockstep with `docs/PROSE_ROUNDTRIP.md` —
|
||||
// the doc reproduces the literal text so a human can assemble the
|
||||
// same prompt by hand.
|
||||
format!(
|
||||
"You are integrating prose edits back into an AILang module.
|
||||
|
||||
ROLE
|
||||
Your job is to produce an updated AILang JSON-AST (.ail.json) that
|
||||
reflects the human's prose edits while preserving the load-bearing
|
||||
semantic detail from the original .ail.json.
|
||||
|
||||
CONTRACT
|
||||
The prose is the source of intent: the human edited it to express
|
||||
what the program should now do. The original .ail.json carries
|
||||
load-bearing detail that the prose surface elides — preserve those
|
||||
details unless the prose explicitly contradicts them. Specifically:
|
||||
|
||||
- Mode annotations on fn parameters (`own T`, `borrow T`).
|
||||
These are hard contracts (memory model). The prose shows them,
|
||||
but if the prose is ambiguous, default to the original.
|
||||
- Effect annotations on return types (`with IO`, `with Diverge`).
|
||||
Prose shows these too, but again: if uncertain, keep what the
|
||||
original had.
|
||||
- `tail` flags on calls. The prose prints `tail f(x)`; if the
|
||||
edit moved a call, decide whether the new position is still in
|
||||
tail position and flag accordingly.
|
||||
- Doc strings (`///` lines).
|
||||
- Type annotations on signatures and lambdas.
|
||||
- Constructor names and arities (the prose `Cons(h, t)` must
|
||||
round-trip to a `Term::Ctor` with the right `type` field).
|
||||
|
||||
OUTPUT
|
||||
Output ONLY the new .ail.json bytes. No commentary, no markdown
|
||||
fences, no preamble or postscript. The output must be valid JSON
|
||||
parsable by `ail parse` (i.e. by `serde_json` against the
|
||||
`ailang/v0` schema). The first byte should be `{{` and the last
|
||||
non-whitespace byte should be `}}`.
|
||||
|
||||
SCHEMA ESSENTIALS
|
||||
A full reference lives in docs/DESIGN.md (\"Data model (MVP)\"). The
|
||||
shape is:
|
||||
|
||||
- Module: {{ \"schema\": \"ailang/v0\", \"name\": ..., \"imports\": [...],
|
||||
\"defs\": [...] }}
|
||||
- Defs are tagged via `kind` (\"fn\" | \"type\" | \"const\").
|
||||
- Types are tagged via `k` (\"con\" | \"fn\" | \"var\" | \"forall\").
|
||||
- Terms are tagged via `t` (\"lit\" | \"var\" | \"app\" | \"let\" | \"if\"
|
||||
| \"do\" | \"ctor\" | \"match\" | \"lam\" | \"seq\").
|
||||
- Patterns are tagged via `p` (\"var\" | \"lit\" | \"ctor\" | \"wild\").
|
||||
- On `fn-type`: `param_modes` (one of \"own\"/\"borrow\" per
|
||||
parameter) and `effects` are mandatory. `ret_mode` is mandatory
|
||||
when the return type carries a mode.
|
||||
- Constructor application uses `Term::Ctor` (`\"t\": \"ctor\"`,
|
||||
fields `type` + `ctor` + `args`), NOT `Term::App`. The prose
|
||||
surface elides the `type` tag (`Cons(h, t)` instead of
|
||||
`(term-ctor IntList Cons h t)`) — re-introduce it.
|
||||
- Base type names that appear bare in prose (`Int`, `Bool`,
|
||||
`Unit`, user types like `IntList`) must be wrapped as
|
||||
`{{\"k\": \"con\", \"name\": \"Int\"}}` etc. in the JSON.
|
||||
|
||||
ORIGINAL .ail.json
|
||||
<<<ORIGINAL_AIL_JSON
|
||||
{orig}
|
||||
ORIGINAL_AIL_JSON
|
||||
|
||||
EDITED PROSE
|
||||
<<<EDITED_PROSE
|
||||
{edited}
|
||||
EDITED_PROSE
|
||||
|
||||
Emit the updated .ail.json now.
|
||||
",
|
||||
orig = orig_ail_json,
|
||||
edited = edited_prose,
|
||||
)
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
@@ -312,6 +417,17 @@ fn main() -> Result<()> {
|
||||
let m = ailang_core::load_module(&path)?;
|
||||
print!("{}", ailang_prose::module_to_prose(&m));
|
||||
}
|
||||
Cmd::MergeProse { original, edited } => {
|
||||
// Iter 20d: read both inputs verbatim, compose the prompt,
|
||||
// print to stdout. No schema validation here — `ail check`
|
||||
// exists for that purpose, and merge-prose is intentionally
|
||||
// a pure prompt-shaper.
|
||||
let orig = std::fs::read_to_string(&original)
|
||||
.with_context(|| format!("reading {}", original.display()))?;
|
||||
let prose = std::fs::read_to_string(&edited)
|
||||
.with_context(|| format!("reading {}", edited.display()))?;
|
||||
print!("{}", compose_merge_prose_prompt(&orig, &prose));
|
||||
}
|
||||
Cmd::Describe { path, name, json, workspace } => {
|
||||
if workspace {
|
||||
let ws = ailang_core::load_workspace(&path)?;
|
||||
@@ -1790,3 +1906,86 @@ fn build_to(
|
||||
}
|
||||
Ok(out_bin)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compose_merge_prose_prompt;
|
||||
|
||||
/// The composed prompt must contain both inputs byte-for-byte —
|
||||
/// the LLM has to see exactly what the user wrote, with no
|
||||
/// re-encoding or stripping.
|
||||
#[test]
|
||||
fn merge_prose_prompt_contains_both_inputs_verbatim() {
|
||||
let orig = "{\"schema\":\"ailang/v0\",\"name\":\"foo\",\"defs\":[]}";
|
||||
let prose = "// module foo\n\nfn bar() -> Int { 42 }\n";
|
||||
let out = compose_merge_prose_prompt(orig, prose);
|
||||
assert!(
|
||||
out.contains(orig),
|
||||
"prompt missing original .ail.json verbatim; got:\n{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(prose),
|
||||
"prompt missing edited prose verbatim; got:\n{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The framing sections must all be present so the LLM has the
|
||||
/// full task definition. Asserted by substring on a representative
|
||||
/// landmark from each section.
|
||||
#[test]
|
||||
fn merge_prose_prompt_has_all_framing_sections() {
|
||||
let out = compose_merge_prose_prompt("{}", "");
|
||||
// Role statement.
|
||||
assert!(
|
||||
out.contains("ROLE")
|
||||
&& out.contains("integrating prose edits"),
|
||||
"prompt missing ROLE section"
|
||||
);
|
||||
// Contract about preserving load-bearing detail.
|
||||
assert!(
|
||||
out.contains("CONTRACT")
|
||||
&& out.contains("preserve those")
|
||||
&& out.contains("`own T`")
|
||||
&& out.contains("`with IO`"),
|
||||
"prompt missing CONTRACT section"
|
||||
);
|
||||
// Output spec — JSON only, no fences, no commentary.
|
||||
assert!(
|
||||
out.contains("OUTPUT")
|
||||
&& out.contains("ONLY")
|
||||
&& out.contains("no markdown"),
|
||||
"prompt missing OUTPUT section"
|
||||
);
|
||||
// Schema-essentials reminder + DESIGN.md pointer.
|
||||
assert!(
|
||||
out.contains("SCHEMA ESSENTIALS")
|
||||
&& out.contains("docs/DESIGN.md")
|
||||
&& out.contains("`kind`")
|
||||
&& out.contains("`k`")
|
||||
&& out.contains("`t`")
|
||||
&& out.contains("`p`")
|
||||
&& out.contains("param_modes")
|
||||
&& out.contains("Term::Ctor"),
|
||||
"prompt missing SCHEMA ESSENTIALS section"
|
||||
);
|
||||
// Heredoc-style markers around both payloads.
|
||||
assert!(
|
||||
out.contains("<<<ORIGINAL_AIL_JSON")
|
||||
&& out.contains("ORIGINAL_AIL_JSON\n")
|
||||
&& out.contains("<<<EDITED_PROSE")
|
||||
&& out.contains("EDITED_PROSE\n"),
|
||||
"prompt missing payload markers"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pure / deterministic: same inputs must yield byte-identical
|
||||
/// output.
|
||||
#[test]
|
||||
fn merge_prose_prompt_is_deterministic() {
|
||||
let orig = "{\"x\":1}";
|
||||
let prose = "fn f() {}";
|
||||
let a = compose_merge_prose_prompt(orig, prose);
|
||||
let b = compose_merge_prose_prompt(orig, prose);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user