Skip to content
Merged
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
2 changes: 2 additions & 0 deletions architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,8 @@ kinds of interviews that the Weaver can produce.
- `editor_agent.py` runs the bounded agent loop and the explicit final validation pass
- `document_bundles.py` reads and edits the documents an interview assembles, and reports which template files nothing in the interview uses yet: which `ALDocument` fills which template, what order each `ALDocumentBundle` lists them in, and the `enabled` rule that decides whether one is in the download. Both edits rewrite a single keyword argument inside one `objects:` declaration, leaving the rest of the block's text and comments alone
- `template_analysis.py` is the engine behind the editor's **Import into this interview** action: it runs the generator over one template and keeps only what an existing interview is missing -- the `attachment` block, screens for fields nothing asks about yet, and the `objects` those screens need. On a template already imported it offers a freshly read attachment block instead, which is how a form the court has revised gets its new fields. Reading a template stays available for the life of a project, not only while it is being created
- Template setup also removes individual bundle entries or deletes a document's declaration, attachment, title, and bundle references on Save. Template files and questions are retained; computed bundle lists require YAML editing. Authors must review custom code that refers to a deleted document.
- `attachment_editor.py` powers the field mapping dialog for standalone attachments and attachments on question screens. It reads PDF/DOCX fields using the generator's extraction helpers and patches only changed scalar values or newly mapped fields. The authenticated `/api/attachment-mappings` endpoint checks the source revision before saving. Missing PDF rows are flagged; DOCX's implicit interview-variable context and dynamic mapping directives are explained separately. Complex values remain read-only, with YAML mode available for advanced edits.
- `review_screen.py` groups the review screen a generated interview gets: one entry per question screen, in asking order, with `.revisit` entries for lists, and it decides which attributes a revisit table's `edit:` may name
- `review_screen_sync.py` re-drafts a review screen for an interview that already exists, so one that has drifted from the questions can be brought back in line without hand-editing
- `variable_report.py` drafts a starter DOCX template from the questions an interview already asks, for intakes where the answers are the output and there is no form to start from
Expand Down
124 changes: 123 additions & 1 deletion docassemble/ALWeaver/api_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ class DAInvalidFilename(Exception): # type: ignore[no-redef]
)
from .document_bundles import (
interview_documents,
remove_document,
set_bundle_elements,
set_enabled_expression,
template_status,
Expand Down Expand Up @@ -10040,6 +10041,111 @@ def editor_api_apply_template_analysis() -> Response:
)


@app.route(f"{EDITOR_BASE_PATH}/api/attachment-mappings", methods=["POST"])
def editor_api_attachment_mappings() -> Response:
"""Read actual template fields or save surgical, revision-checked field edits."""
from .attachment_editor import attachment_mappings, update_attachment_mappings

request_id = str(uuid.uuid4())
if not _editor_auth_check():
return _auth_fail(request_id)
try:
payload = request.get_json(silent=True) or {}
uid = _current_user_id()
project = _normalize_project(payload.get("project"))
filename = _normalize_filename(payload.get("filename"))
content = playground_read_yaml(uid, project, filename)
blocks = parse_interview_yaml(content)["blocks"]
matches = [block for block in blocks if block["id"] == payload.get("block_id")]
if len(matches) != 1:
raise ValueError("Select a unique attachment block.")
block = matches[0]
if "updates" in payload:
if payload.get("expected_revision") != source_revision(content):
return jsonify_with_status(
{
"success": False,
"request_id": request_id,
"error": {
"type": "revision_conflict",
"message": "This interview changed. Reopen the field editor before saving.",
},
},
409,
)
if not isinstance(payload["updates"], list):
raise ValueError("updates must be a list")
updated = update_attachment_mappings(block["yaml"], payload["updates"])
content = update_block_in_yaml(content, block["id"], updated)
playground_write_yaml(uid, project, filename, content)
return jsonify({"success": True, "request_id": request_id})

attachments = attachment_mappings(block["yaml"])
for attachment in attachments:
attachment["template_fields"] = []
try:
template = _normalize_storage_filename(attachment["template"])
if (
template != attachment["template"]
or ":" in template
or "${" in template
):
raise ValueError(
"Template reference is external or computed; edit its mappings below or use YAML mode."
)
# Reading fields must not rename or modify the template.
_, directory = _editor_storage_directory(
uid, project, EDITOR_SECTION_TO_STORAGE["templates"]
)
path = os.path.join(directory, template)
if not os.path.isfile(path) or os.path.islink(path):
raise ValueError("Template is not a local project file.")
if not template.lower().endswith((".pdf", ".docx")):
raise ValueError("Choose a PDF or DOCX template in YAML mode.")
from .interview_generator import _make_static_file_from_path, get_fields

fields = get_fields(
cast(Any, _make_static_file_from_path(path, filename=template))
)
attachment["template_fields"] = list(
dict.fromkeys(
str(item[0] if template.lower().endswith(".pdf") else item)
for item in fields
)
)
except Exception as exc:
attachment["warning"] = f"Could not check template fields: {exc}"
return jsonify(
{
"success": True,
"request_id": request_id,
"data": {
"revision": source_revision(content),
"attachments": attachments,
},
}
)
except (ValueError, yaml.YAMLError, FileNotFoundError) as exc:
return jsonify_with_status(
{
"success": False,
"request_id": request_id,
"error": {"type": "validation_error", "message": str(exc)},
},
400,
)
except Exception as exc:
log(f"ALWeaver editor: attachment mappings error: {exc!r}", "error")
return jsonify_with_status(
{
"success": False,
"request_id": request_id,
"error": {"type": "server_error", "message": str(exc)},
},
500,
)


@app.route(f"{EDITOR_BASE_PATH}/api/documents", methods=["GET"])
def editor_api_documents() -> Response:
"""List the documents an interview assembles, and the bundles they sit in."""
Expand Down Expand Up @@ -10108,11 +10214,16 @@ def editor_api_save_documents() -> Response:
raise ValueError("expected_revision is required")
bundle_updates = post_data.get("bundles") or []
enabled_updates = post_data.get("enabled") or []
removals = post_data.get("remove") or []
if not isinstance(removals, list) or any(
not isinstance(name, str) for name in removals
):
raise ValueError("remove must be a list of document names")
if not isinstance(bundle_updates, list) or not isinstance(
enabled_updates, list
):
raise ValueError("bundles and enabled must be lists")
if not bundle_updates and not enabled_updates:
if not bundle_updates and not enabled_updates and not removals:
raise ValueError("Nothing was changed.")

content = playground_read_yaml(uid, project, filename)
Expand Down Expand Up @@ -10152,6 +10263,17 @@ def editor_api_save_documents() -> Response:
expression = None if raw_expression is None else str(raw_expression)
content = set_enabled_expression(content, name, expression)

for name in removals:
content = remove_document(content, name)

# Deleting a declaration must not leave an unresolved YAML alias.
try:
list(yaml.compose_all(content))
except yaml.YAMLError as exc:
raise ValueError(
f"The document changes would invalidate the YAML: {exc}"
) from exc

playground_write_yaml(uid, project, filename, content)
updated_model = parse_interview_yaml(content)
data = interview_documents(content).to_dict()
Expand Down
Loading
Loading