Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ Decodes PDFs and extracts structured data for automated forms conversion.
## Prerequisites

- [Rust](https://rustup.rs/) (edition 2024)
- [Dioxus CLI](https://dioxuslabs.com/learn/0.6/getting_started) — only needed for the desktop app
- [Dioxus CLI](https://dioxuslabs.com/learn/0.7/getting_started/) — only needed for the desktop app

Dioxus can easily be installed using cargo-binstall:

```sh
cargo install cargo-binstall
cargo binstall dioxus-cli@0.7.3
cargo binstall dioxus-cli@0.7.9
```

After installing, make sure `dx --version` prints `dioxus 0.7.9`. If it
prints Deno help or executes `deno x`, your shell is resolving a different
`dx` binary first. Put `~/.cargo/bin` before that binary in `PATH`, or call
the Dioxus CLI directly as `~/.cargo/bin/dx`.

In order to version large files we need the git lfs extension

```sh
Expand Down Expand Up @@ -108,18 +113,18 @@ The app is built with [Dioxus](https://dioxuslabs.com/) and targets the desktop.

It bundles an AI conversion agent that drives the engine's tools turn by turn to convert a form interactively. The agent uses the Anthropic API — set the API key and model (default `claude-opus-4-8`) in the app's settings. Every tree change is versioned into a local edit-history SQLite database, so conversions can be reviewed and resumed.

### Development
### Developmentt

```sh
cd app
dx serve --platform desktop
~/.cargo/bin/dx serve --platform desktop --package blueprint-app
```

### Production Build

```sh
cd app
dx build --release --platform desktop
~/.cargo/bin/dx build --release --platform desktop --package blueprint-app
```

## MCP Server
Expand Down
92 changes: 75 additions & 17 deletions agent/src/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ language and see the rendered pages. Steps:\n\
a. Read each state with get_flattened_structure_for_state (every language × every configurator \
selection, e.g. EN/Private-Person, DE/Company) plus its page image. The XFA is the authority for \
verbatim text in each language; the images are the authority for layout and section order.\n\
b. Build the whole tree in one set_aem_translated call: lay out the sections in source order; \
b. Author the tree. A small form may be passed whole in one set_aem_translated call, but for a \
large form (many sections/fields, repeatable sections, or several languages) do NOT emit it all at \
once — a single tool call whose output exceeds the per-turn limit is cut off and discarded in full. \
Instead set a skeleton first with set_aem_translated (the root plus the top-level panels/sections and \
their titles), then add each section's fields with insert_aem_translated_node, building the tree up in \
small calls. Lay out the sections in source order; \
for every text field include EVERY source language (pair translations by meaning and layout \
position — never leave a language blank or collapse to one); give each fillable field the right \
component type, options (real labels AND values), required/visible state and column width; nest \
Expand Down Expand Up @@ -478,6 +483,51 @@ impl ConversionAgent {
self.package.clone()
}

/// `true` once a working AEM (translated) tree has been authored.
pub fn has_aem_tree(&self) -> bool {
self.aem_translated.is_some()
}

/// Guarantee a downloadable package when one can be built.
///
/// Every tree edit invalidates the package (see [`Self::aem_translated_edited`]),
/// and it is only rebuilt when the agent explicitly calls `build_aem_package`.
/// If a run finishes right after an edit, `self.package` is `None` and the UI
/// has nothing to offer for download. Call this at the end of a run: when a
/// working AEM tree exists but no package is current, build one from the
/// latest tree.
///
/// Returns `Ok(None)` when no tree has been authored yet (nothing to build),
/// `Ok(Some(pkg))` for the current/just-built package, and `Err` when a tree
/// exists but packaging failed — so the caller can surface *why* there is no
/// download instead of silently showing none.
pub fn ensure_package(&mut self) -> Result<Option<Vec<u8>>, String> {
if self.package.is_none() && self.aem_translated.is_some() {
let pkg = self.build_package()?;
self.package = Some(pkg);
}
Ok(self.package.clone())
}

/// Build the AEM package from the current translated tree, isolating any
/// panic in the package writer (it relies on many internal
/// `.unwrap()`/`.expect()` calls) so a build failure becomes a recoverable
/// error instead of aborting the whole conversion task. Assumes a tree
/// exists; callers check `aem_translated` first.
fn build_package(&mut self) -> Result<Vec<u8>, String> {
let cfg = self.config()?;
let (aem, translations) = self.lower_aem_translated()?;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations)
}))
.map_err(|_| {
"The AEM package writer failed unexpectedly while building the \
package. The tree may contain an unsupported shape — inspect it \
with get_aem_translated_outline and simplify the offending node."
.to_string()
})
}

/// The resolved form code, if the AEM config has been loaded.
pub fn form_code(&self) -> Option<String> {
self.aem_config.as_ref().map(|c| c.form_code.clone())
Expand Down Expand Up @@ -668,7 +718,7 @@ impl ConversionAgent {
// §2 multilingual AEM tree (AemNodeTranslated) — authored directly.
t(
"set_aem_translated",
"Set the WHOLE working AEM tree as an AemNodeTranslated JSON object (call get_schema('aem_translated') for the exact shape). Use this for the initial authoring of the form; for small fixes afterwards use the targeted editors below. Text fields (title/label/content and option labels) are per-language maps like {\"de\":\"…\",\"en\":\"…\"}; include EVERY source language. Invalidates the package.",
"Set the WHOLE working AEM tree as an AemNodeTranslated JSON object (call get_schema('aem_translated') for the exact shape). Use this for initial authoring; for a large form set only a skeleton here (root + top-level panels/sections with titles) and add each section's fields with insert_aem_translated_node, since a single call whose output exceeds the per-turn limit is cut off and discarded in full. For small fixes afterwards use the targeted editors below. Text fields (title/label/content and option labels) are per-language maps like {\"de\":\"…\",\"en\":\"…\"}; include EVERY source language. Invalidates the package.",
serde_json::json!({"root": {"type":"object"}}),
serde_json::json!(["root"]),
),
Expand Down Expand Up @@ -1081,21 +1131,14 @@ impl ConversionAgent {
}

// §5 output
"build_aem_package" => {
let cfg = match self.config() {
Ok(c) => c,
Err(e) => return ToolReply::Error(e),
};
let (aem, translations) = match self.lower_aem_translated() {
Ok(pair) => pair,
Err(e) => return ToolReply::Error(e),
};
let pkg =
blueprint::to_aem_package_from_node_with_translations(&aem, &cfg, translations);
let size = pkg.len();
self.package = Some(pkg);
ToolReply::Text(format!("Built package ({size} bytes)."))
}
"build_aem_package" => match self.build_package() {
Ok(pkg) => {
let size = pkg.len();
self.package = Some(pkg);
ToolReply::Text(format!("Built package ({size} bytes)."))
}
Err(e) => ToolReply::Error(e),
},
"get_package_info" => match &self.package {
Some(pkg) => {
let files = crate::references::unzip_package(pkg).unwrap_or_default();
Expand Down Expand Up @@ -1470,6 +1513,21 @@ mod tests {
);
}

#[test]
fn ensure_package_without_tree_is_ok_none() {
// No AEM tree authored yet → nothing to build, and crucially NOT an
// error: ensure_package distinguishes "no tree" (Ok(None)) from "a tree
// exists but packaging failed" (Err) so finalize can tell the user why
// there is no download instead of silently showing none.
let mut agent = ConversionAgent::new(
Some("ubs".into()),
Vec::new(),
None,
"test-ensure-package".into(),
);
assert!(matches!(agent.ensure_package(), Ok(None)));
}

#[test]
fn form_path_trims_slashes() {
assert_eq!(
Expand Down
Loading
Loading