Skip to content

Commit

Permalink
Merge pull request #909 from SuffolkLITLab/author-from-objects
Browse files Browse the repository at this point in the history
Allow uploading a "script" for interview auto drafting mode
  • Loading branch information
nonprofittechy authored Dec 14, 2023
2 parents e909940 + c1d31ed commit cfcd44e
Show file tree
Hide file tree
Showing 3 changed files with 237 additions and 3 deletions.
46 changes: 45 additions & 1 deletion docassemble/ALWeaver/data/questions/assembly_line.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1893,6 +1893,29 @@ fields:
choices:
- Build step by step: False
- Use auto-drafting mode: True
- Upload JSON file with draft screen arrangement (advanced): interview.start_with_json
help: |
If you have a JSON file that has a draft of the screens and their order, you can upload
it now. This is intended to make it possible to draft the screens in another tool, such
as with the help of GPT-4. You still get the benefit of the Weaver's templates
and pre-built questions.
Your uploaded JSON file should be a dictionary with two keys:
1. `questions` should be a list of dictionaries, each of which represents a screen with
"custom" fields.
2. `interview order` should be a list of strings, each of which is the name of a screen. It
can include "built-in" screens you want to let the Weaver control, such as users.gather().
datatype: yesno
show if:
variable: im_feeling_lucky
is: True
- JSON file: interview.uploaded_json
datatype: file
file css class: None
show if: interview.start_with_json
accept: |
"application/json"
- Try to install the `en_core_web_lg` package before using auto drafting mode: install_en_core_web_lg
help: |
Installing `en_core_web_lg` will allow you to use automatic field
Expand Down Expand Up @@ -1970,8 +1993,29 @@ code: |
yes_recognize_form_fields = False
interview_type = "regular"
if not has_safe_pdf_in_url:
if hasattr(interview, "start_with_json") and interview.start_with_json:
#try: # Don't know yet why this always raises an exception. A name error that DA silently fixes?
interview.parsed_json = json.loads(interview.uploaded_json.slurp())
#except:
# log("Unable to parse JSON file with json.loads", "error")
# interview.parsed_json = None
if isinstance(interview.parsed_json, dict) and "questions" in interview.parsed_json and "interview order" in interview.parsed_json:
interview.auto_assign_attributes(
screens=interview.parsed_json["questions"],
interview_logic = interview.parsed_json["interview order"]
)
if isinstance(interview.parsed_json, dict) and "questions" in interview.parsed_json:
# Let author upload just the "questions" dict without "interview order"
interview.auto_assign_attributes(screens=interview.parsed_json["questions"])
elif isinstance(interview.parsed_json, list) and next(iter(interview.parsed_json), None) and isinstance(next(iter(interview.parsed_json)), dict):
# Assume the author uploaded a list of screens, without interview order
interview.auto_assign_attributes(screens=interview.parsed_json)
else:
log("Not using JSON file because it has an unexpected format: should be a dict with 'questions' and 'interview order' keys, or a list of question dicts", "error")
interview.auto_assign_attributes()
else:
interview.auto_assign_attributes()
interview_label_draft = interview.short_filename
interview_label_draft = varname(interview.title)
# TODO: refactor this at some point, this is a shim to create objects block but we
# shouldn't need it forever.
Expand Down
4 changes: 4 additions & 0 deletions docassemble/ALWeaver/data/templates/output.mako
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,11 @@ ${ field_entry_yaml(field) }\
% endfor
% endif
% if question.needs_continue_button_field:
% if hasattr(question, "continue_button_field"):
continue button field: ${ question.continue_button_field }
% else:
continue button field: ${ varname(question.question_text) }
% endif
% endif
% endfor
<%doc>
Expand Down
190 changes: 188 additions & 2 deletions docassemble/ALWeaver/interview_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import uuid
import zipfile
import spacy
from dataclasses import dataclass

mako.runtime.UNDEFINED = DAEmpty()

Expand Down Expand Up @@ -79,6 +80,7 @@ def formfyxer_available():
"get_docx_validation_errors",
"get_docx_variables",
"get_fields",
"get_question_file_variables",
"get_pdf_validation_errors",
"get_pdf_variable_name_matches",
"get_variable_name_warnings",
Expand All @@ -97,6 +99,7 @@ def formfyxer_available():
"to_yaml_file",
"using_string",
"varname",
"logic_to_code_block",
]

always_defined = set(
Expand Down Expand Up @@ -204,6 +207,39 @@ def varname(var_name: str) -> str:
return var_name


def logic_to_code_block(items: List[Union[Dict, str]], indent_level=0) -> str:
"""Converts a list of logic items to a code block with the given indentation level
Args:
items (list): A list of logic items, of the form ['var0', {'condition': '...', 'children': ['var1']}, 'var2', 'var3', ...]
indent_level (int, optional): The indentation level to use. Defaults to 0. Used for recursion.
Returns:
str: The code block, as a string
"""
code_lines = []
indent = " " * indent_level # Define the indentation (e.g., 2 spaces per level)
for item in items:
if isinstance(item, str): # If the item is a string, it's a variable
code_lines.append(f"{indent}{item}")
elif isinstance(item, dict): # If the item is a dictionary, it's a condition
# Add the condition line with the current indentation
condition_line = item["condition"]
if not condition_line.startswith("if "):
condition_line = (
"if " + condition_line
) # Add 'if' if it's not already there
if not condition_line.endswith(":"):
condition_line += ":"
code_lines.append(f"{indent}{condition_line}")

# Recursively process the children with increased indentation
children_code = logic_to_code_block(item["children"], indent_level + 1)
code_lines.append(children_code)

return "\n".join(code_lines)


class DAFieldGroup(Enum):
RESERVED = "reserved"
BUILT_IN = "built in"
Expand Down Expand Up @@ -1210,7 +1246,7 @@ class DAQuestion(DAObject):

def init(self, *pargs, **kwargs):
super().init(*pargs, **kwargs)
self.field_list = DAFieldList()
self.initializeAttribute("field_list", DAFieldList)

@property
def complete(self) -> bool:
Expand Down Expand Up @@ -1344,6 +1380,48 @@ def interview_order_list(
return list(more_itertools.unique_everseen(logic_list))


class DADataType(Enum):
TEXT = "text"
AREA = "area"
YESNO = "yesno"
NOYES = "noyes"
YESNORADIO = "yesnoradio"
NOYESRADIO = "noyesradio"
YESNOWIDE = "yesnowide"
NOYESWIDE = "noyeswide"
NUMBER = "number"
INTEGER = "integer"
CURRENCY = "currency"
EMAIL = "email"
DATE = "date"
FILE = "file"
RADIO = "radio"
COMBOBOX = "combobox"
CHECKBOXES = "checkboxes"


@dataclass
class Field:
label: Optional[str] = None
field: Optional[str] = None
datatype: Optional[DADataType] = None
input_type: Optional[str] = None
maxlength: Optional[int] = None
choices: Optional[List[str]] = None
min: Optional[int] = None
max: Optional[int] = None
step: Optional[int] = None
required: Optional[bool] = None


@dataclass
class Screen:
continue_button_field: Optional[str] = None
question: Optional[str] = None
subquestion: Optional[str] = None
fields: List[Field] = None


class DAInterview(DAObject):
"""
This class is a container for the various questions and metadata
Expand Down Expand Up @@ -1486,11 +1564,24 @@ def auto_assign_attributes(
jurisdiction: Optional[str] = None,
categories: Optional[str] = None,
default_country_code: str = "US",
interview_logic: Optional[List[Union[Dict, str]]] = None,
screens: Optional[List[Dict]] = None,
):
"""
Automatically assign interview attributes based on the template
assigned to the interview object.
To assist with "I'm feeling lucky" button.
Args:
url (Optional[str]): URL to a template file
input_file (Optional[Union[DAFileList, DAFile, DAStaticFile]]): A file
object
title (Optional[str]): Title of the interview
jurisdiction (Optional[str]): Jurisdiction of the interview
categories (Optional[str]): Categories of the interview
default_country_code (str): Default country code for the interview. Defaults to "US".
interview_logic (Optional[List[Union[Dict, str]]]): Interview logic, represented as a tree
screens (Optional[List[Dict]]): Interview screens, represented in the same structure as Docassemble's dictionary for a question block
"""
try:
if user_logged_in():
Expand Down Expand Up @@ -1540,7 +1631,14 @@ def auto_assign_attributes(
self._auto_load_fields()
self.all_fields.auto_label_fields()
self.all_fields.auto_mark_people_as_builtins()
self.auto_group_fields()
if interview_logic:
self.interview_logic = interview_logic
if screens:
if not interview_logic:
self.interview_logic = get_question_file_variables(screens)
self.create_questions_from_screen_list(screens)
else:
self.auto_group_fields()

def _set_title(self, url=None, input_file=None):
if url:
Expand Down Expand Up @@ -1849,6 +1947,70 @@ def _guess_categories(self, title) -> List[str]:
def _null_group_fields(self):
return {"Screen 1": [field.variable for field in self.all_fields.custom()]}

def create_questions_from_screen_list(self, screen_list: List[Screen]):
"""
Create a question for each screen in the screen list. This is an alternative to
allow an author to upload a list of fields and then create a question for each
without using FormFyxer's auto field creation.
Args:
screen_list (list): A list of dictionaries, each representing a screen
"""
self.questions.auto_gather = False
for screen in screen_list:
if not screen.get("question"):
continue
new_screen = self.questions.appendObject()
if screen.get("continue button field"):
new_screen.continue_button_field = screen.get("continue button field")
new_screen.is_informational = True
else:
new_screen.is_informational = False
new_screen.question_text = screen.get("question", "")
new_screen.subquestion_text = screen.get("subquestion", "")
for field in screen.get("fields", []):
new_field = new_screen.field_list.appendObject()

if field.get("label") and field.get("field"):
new_field.variable = field.get("field")
new_field.label = field.get("label")
else:
first_item = next(iter(field.items()))
new_field.variable = first_item[1]
new_field.label = first_item[0]
# For some reason we made the field_type not exactly the same as the datatype in Docassemble
# TODO: consider refactoring this
if field.get("datatype") or field.get("input type"):
if field.get("datatype", "") == "radio":
new_field.field_type = "multiple choice radio"
elif field.get("datatype", "") == "checkboxes":
new_field.field_type = "multiple choice checkboxes"
elif field.get("datatype", "") == "dropdown":
new_field.field_type = "multiple choice dropdown"
elif field.get("datatype", "") == "combobox":
new_field.field_type = "multiple choice combobox"
else:
new_field.field_type = field.get(
"datatype", field.get("input type", "text")
)
else:
new_field.field_type = "text"
if field.get("maxlength"):
new_field.maxlength = field.get("maxlength", None)
if field.get("choices"):
# We turn choices into a newline separated string
new_field.choices = "\n".join(field.get("choices", []))
if field.get("min"):
new_field.range_min = field.get("min", None)
if field.get("max"):
new_field.range_max = field.get("max", None)
if field.get("step"):
new_field.range_step = field.get("step", None)
if field.get("required") == False:
new_field.is_optional = True
new_screen.field_list.gathered = True
self.questions.gathered = True

def auto_group_fields(self):
"""
Use FormFyxer to assign fields to screens.
Expand Down Expand Up @@ -2003,6 +2165,30 @@ def get_fields(document: Union[DAFile, DAFileList]) -> Iterable:
return get_docx_variables(text)


def get_question_file_variables(screens: List[Screen]) -> List[str]:
"""Extract the fields from a list of screens representing a Docassemble interview,
such as might be supplied as an input to the Weaver in JSON format.
Args:
screens (List[Screen]): A list of screens, each represented as a dictionary
Returns:
List[str]: A list of variables
"""
fields = []
for screen in screens:
if screen.get("continue button field"):
fields.append(screen.get("continue button field"))
if screen.get("fields"):
for field in screen.get("fields"):
if field.get("field"):
fields.append(field.get("field"))
else:
fields.append(next(iter(field.values())))
# remove duplicates without changing order
return list(dict.fromkeys(fields))


def get_docx_variables(text: str) -> set:
"""
Given the string from a docx file with fairly simple
Expand Down

0 comments on commit cfcd44e

Please sign in to comment.