Skip to content

Commit bc24bc4

Browse files
Merge pull request #304 from SuffolkLITLab/nicer-questionnaire
Reduce latency, add tests for questionnaire feature
2 parents 105bb77 + dc89276 commit bc24bc4

File tree

4 files changed

+254
-32
lines changed

4 files changed

+254
-32
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ docassemble.ALToolbox.egg-info/**
1010

1111
# Python testing stuff
1212
.coverage
13-
coverage_html
13+
coverage_html
14+
.env

docassemble/ALToolbox/data/questions/goal_oriented_question_structured_demo.yml

Lines changed: 92 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,40 @@ include:
33
- goal_oriented_question_structured.yml
44
---
55
metadata:
6-
title: Legal Aid Intake - Structured Initial Question Demo
7-
short title: Structured Intake Demo
6+
title: Legal Aid Intake - Goal-Oriented Questions Demo (Structured or Unstructured)
7+
short title: Flexible Intake Demo
88
description: |
9-
This interview demonstrates using GoalOrientedQuestionList with a
10-
structured initial question (using radio buttons, checkboxes, etc.)
11-
instead of an open-ended narrative response.
9+
This interview demonstrates using GoalOrientedQuestionList with either
10+
a structured initial question (using radio buttons, checkboxes, etc.)
11+
or an open-ended narrative response. You can choose the format that works
12+
best for your use case.
1213
---
1314
objects:
14-
- housing_intake: GoalOrientedQuestionList.using(rubric="The response provides complete information about the tenant's housing situation, including the type of issue, timeline, communication with landlord, and any immediate safety or habitability concerns.", initial_question="We need to understand your housing situation to determine how we can help. Please provide information about your housing issue.", llm_assumed_role="housing intake specialist", user_assumed_role="tenant seeking help")
15+
- housing_intake: GoalOrientedQuestionList.using(
16+
rubric="The response provides complete information about the tenant's housing situation, including the type of issue, timeline, communication with landlord, and any immediate safety or habitability concerns.",
17+
initial_question="We need to understand your housing situation to determine how we can help. Please provide information about your housing issue.",
18+
llm_assumed_role="housing intake specialist",
19+
user_assumed_role="tenant seeking help",
20+
model=model,
21+
reasoning_effort=reasoning_effort,
22+
skip_moderation=skip_moderation,
23+
)
1524
---
1625
mandatory: True
1726
code: |
1827
intro_screen
19-
# Trigger the initial structured question
20-
housing_intake._initial_structured_complete
28+
question_format_choice
29+
30+
# Set the use_structured_initial_question based on user choice
31+
if question_format == "structured":
32+
housing_intake.use_structured_initial_question = True
33+
# Trigger the initial structured question
34+
housing_intake._initial_structured_complete
35+
else:
36+
housing_intake.use_structured_initial_question = False
37+
# Trigger the initial open-ended question
38+
housing_intake.initial_draft
39+
2140
housing_intake.gather()
2241
final_response
2342
intake_summary
@@ -28,27 +47,80 @@ code: |
2847
else:
2948
final_response_default = housing_intake.initial_response_as_text()
3049
---
50+
code: |
51+
# This code block is needed to define the question_format_choice step
52+
question_format_choice = True
53+
---
3154
continue button field: intro_screen
3255
question: |
33-
Welcome to Housing Legal Aid Intake (Structured)
56+
Welcome to Housing Legal Aid Intake
3457
subquestion: |
3558
This demo shows how the **GoalOrientedQuestionList** can use structured
36-
fields (radio buttons, checkboxes, etc.) for the initial question instead
37-
of requiring an open-ended narrative.
59+
fields (radio buttons, checkboxes, etc.) or an open-ended narrative for
60+
the initial question.
3861
3962
#### Scenario
4063
4164
You are seeking help with a housing issue. An intake worker needs to understand
42-
your situation using a structured form.
65+
your situation to determine if you qualify for services and to prepare for
66+
your initial consultation.
4367
4468
#### How it works:
4569
46-
1. The AI will generate structured fields for the initial question based on the rubric
47-
2. You'll answer using radio buttons, checkboxes, dates, etc. (not just a text area)
48-
3. The AI will then ask follow-up questions if more information is needed
49-
4. Your responses will be synthesized into a complete intake summary
70+
1. You'll choose between a structured form or an open-ended question
71+
2. You'll provide information about your housing situation
72+
3. The AI will evaluate your response to see if it provides enough detail
73+
4. If more information is needed, you'll get follow-up questions
74+
5. Your responses will be synthesized into a complete intake summary
75+
76+
This approach allows you to choose the most appropriate format for data collection.
77+
---
78+
question: |
79+
Choose your initial question format
80+
subquestion: |
81+
How would you like to provide information about your housing situation?
82+
fields:
83+
- Question format: question_format
84+
datatype: radio
85+
choices:
86+
- Structured form with specific fields (AI-generated): structured
87+
- Open-ended narrative response: unstructured
88+
- Reasoning effort: reasoning_effort
89+
datatype: radio
90+
choices:
91+
- Minimal: minimal
92+
- Low effort (faster responses, less depth): low
93+
- Medium effort (balanced speed and depth): medium
94+
- High effort (more thoughtful responses, slower): high
95+
- Skip moderation: skip_moderation
96+
datatype: yesnoradio
97+
default: True
98+
- Model: model
99+
datatype: radio
100+
choices:
101+
- gpt-4.1-nano
102+
- gpt-5-nano
103+
- gpt-5-mini
104+
---
105+
question: |
106+
Tell us about your housing situation
107+
subquestion: |
108+
To help us prepare for your consultation, please tell us about your housing issue.
109+
110+
You can describe:
111+
112+
* What type of problem you're having (repairs, eviction, etc.)
113+
* When the issue started
114+
* What you've tried to do about it
115+
* Any communication with your landlord
116+
* Whether there are safety or health concerns
50117
51-
This approach is useful when you want more structured data collection from the start.
118+
Don't worry if you're not sure what's important—just tell us about your
119+
situation in your own words.
120+
fields:
121+
- Your response: housing_intake.initial_draft
122+
datatype: area
123+
rows: 8
52124
---
53125
question: |
54126
Review your intake information
@@ -97,17 +169,17 @@ subquestion: |
97169
98170
#### About this demo
99171
100-
This demo used a **GoalOrientedQuestionList** with structured initial question:
172+
This demo used a **GoalOrientedQuestionList** with:
101173
102-
* **Initial question format**: Structured fields (generated by AI)
174+
* **Initial question format**: ${ "Structured fields (generated by AI)" if question_format == "structured" else "Open-ended narrative" }
103175
* **Question limit**: ${ housing_intake.question_limit } follow-up questions maximum
104176
* **Follow-ups asked**: ${ len(housing_intake) }
105177
* **Sufficient information gathered**: ${ "Yes" if housing_intake.satisfied() else "Partially" }
106178
* **Model**: ${ housing_intake.model }
107179
* **LLM role**: ${ housing_intake.llm_assumed_role }
108180
* **User role**: ${ housing_intake.user_assumed_role }
109181
110-
The AI generated structured fields for the initial question and then asked follow-ups until the response met this rubric:
182+
The AI ${ "generated structured fields for the initial question and then" if question_format == "structured" else "" } asked follow-ups until the response met this rubric:
111183
112184
> ${ housing_intake.rubric }
113185
buttons:

docassemble/ALToolbox/llms.py

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List, Optional, Union
1+
from typing import Any, Dict, List, Optional, Union, Literal
22
import keyword
33
import os
44
import json
@@ -126,6 +126,7 @@ def chat_completion(
126126
openai_base_url: Optional[str] = None, # "https://api.openai.com/v1/",
127127
max_output_tokens: Optional[int] = None,
128128
max_input_tokens: Optional[int] = None,
129+
reasoning_effort: Optional[Literal["minimal", "low", "medium", "high"]] = None,
129130
) -> Union[List[Any], Dict[str, Any], str]:
130131
"""A light wrapper on the OpenAI chat endpoint.
131132
@@ -144,10 +145,14 @@ def chat_completion(
144145
openai_base_url (Optional[str]): The base URL for the OpenAI API. Defaults to value provided in the configuration or "https://api.openai.com/v1/".
145146
max_output_tokens (Optional[int]): The maximum number of tokens to return from the API. Defaults to 16380.
146147
max_input_tokens (Optional[int]): The maximum number of tokens to send to the API. Defaults to 128000.
148+
reasoning_effort (Optional[Literal["minimal", "low", "medium", "high"]]) = None: The reasoning effort to use for thinking models. Defaults to value provided in the configuration or "low".
147149
148150
Returns:
149151
A string with the response from the API endpoint or JSON data if json_mode is True
150152
"""
153+
if not reasoning_effort:
154+
reasoning_effort = get_config("open ai", {}).get("reasoning effort") or "low"
155+
151156
if not openai_base_url:
152157
openai_base_url = (
153158
get_config("open ai", {}).get("base url") or "https://api.openai.com/v1/"
@@ -242,22 +247,24 @@ def chat_completion(
242247

243248
# Build completion parameters based on model type
244249
if is_thinking_model:
245-
# Thinking models don't support temperature
250+
# Thinking models don't support temperature but do support reasoning_effort
246251
if json_mode:
247252
response = openai_client.chat.completions.create( # type: ignore[call-overload]
248253
model=model,
249254
messages=messages, # type: ignore[arg-type]
250255
response_format={"type": "json_object"},
251256
max_completion_tokens=max_output_tokens,
257+
reasoning_effort=reasoning_effort,
252258
)
253259
else:
254260
response = openai_client.chat.completions.create( # type: ignore[call-overload]
255261
model=model,
256262
messages=messages, # type: ignore[arg-type]
257263
max_completion_tokens=max_output_tokens,
264+
reasoning_effort=reasoning_effort,
258265
)
259266
else:
260-
# Regular models support temperature
267+
# Regular models support temperature but not reasoning_effort
261268
if json_mode:
262269
response = openai_client.chat.completions.create( # type: ignore[call-overload]
263270
model=model,
@@ -1050,6 +1057,8 @@ class GoalOrientedQuestionList(DAList):
10501057
model (str): The model to use for the OpenAI API. Defaults to "gpt-5-nano".
10511058
llm_assumed_role (str): The role for the LLM to assume. Defaults to "legal aid intake worker".
10521059
user_assumed_role (str): The role for the user to assume. Defaults to "applicant for legal help".
1060+
skip_moderation (bool): If True, skips moderation checks when generating structured fields. Defaults to True.
1061+
reasoning_effort (Optional[Literal["minimal", "low", "medium", "high"]]): The level of reasoning effort to use when generating responses. Defaults to "low"; use "minimal" for increased speed.
10531062
"""
10541063

10551064
def init(self, *pargs, **kwargs):
@@ -1072,6 +1081,12 @@ def init(self, *pargs, **kwargs):
10721081
if not hasattr(self, "use_structured_initial_question"):
10731082
self.use_structured_initial_question = False
10741083

1084+
if not hasattr(self, "skip_moderation"):
1085+
self.skip_moderation = True
1086+
1087+
if not hasattr(self, "reasoning_effort"):
1088+
self.reasoning_effort = "low"
1089+
10751090
def generate_initial_question_fields(self) -> Dict[str, Any]:
10761091
"""Generate structured fields for the initial question using the LLM.
10771092
@@ -1083,14 +1098,16 @@ def generate_initial_question_fields(self) -> Dict[str, Any]:
10831098
"""
10841099
system_message = f"""You are a {self.llm_assumed_role} creating an intake form.
10851100
1086-
Based on this question, generate 1-5 structured fields to gather the initial information:
1101+
Based on this question, generate between 1 and 3 structured fields to gather the initial information:
10871102
10881103
Question: {self.initial_question}
10891104
10901105
Goal: {self.rubric}
10911106
1092-
Create structured fields that will help gather relevant information. Use structured question types
1093-
(yesnoradio, radio, checkboxes, date, currency, email) whenever possible.
1107+
Create structured fields that will help gather relevant information.
1108+
1109+
Whenever possible, use structured field types (yes/no, multiple choice, checkboxes, date, currency, etc.).
1110+
Only use open-ended text/area fields when a limited response format is insufficient.
10941111
10951112
Respond with a JSON object in this format:
10961113
{{
@@ -1107,13 +1124,18 @@ def generate_initial_question_fields(self) -> Dict[str, Any]:
11071124
}}
11081125
11091126
Guidelines:
1110-
- Generate 1-5 fields that capture the key information needed
1127+
- Generate 1-3 specific fields that help gather information relevant to the rubric
11111128
- Use yesnoradio for yes/no questions
11121129
- Use radio for single-choice questions (2-5 options)
11131130
- Use checkboxes for multiple-choice questions
11141131
- Use text for short text responses
1115-
- Use area for longer narrative responses
1132+
- Use area when longer narrative is needed
1133+
- Use date only when a precise date is likely to be known, or text when the date is likely to be approximate
1134+
- Use currency for dollar amounts
1135+
- Use email for email addresses
11161136
- All fields must have required: false
1137+
- Write questions and field labels at about a 6th-grade reading level
1138+
- Ask one question per field, avoiding compound questions
11171139
"""
11181140

11191141
results = chat_completion(
@@ -1122,6 +1144,8 @@ def generate_initial_question_fields(self) -> Dict[str, Any]:
11221144
],
11231145
model=self.model,
11241146
json_mode=True,
1147+
skip_moderation=self.skip_moderation,
1148+
reasoning_effort=self.reasoning_effort,
11251149
)
11261150
assert isinstance(results, dict)
11271151
return results
@@ -1270,7 +1294,7 @@ def _check_satisfaction(self) -> Union[str, Dict[str, Any]]:
12701294
If the goal or rubric is satisfied, respond with a JSON object containing only:
12711295
{{"status": "satisfied"}}
12721296
1273-
If the goal or rubric is NOT satisfied, generate a follow-up question with 1-3 specific fields to gather missing information.
1297+
If the goal or rubric is NOT satisfied, generate a follow-up question with no more than 3 specific fields to gather missing information.
12741298
The user will always have an opportunity to provide additional open-ended context in a text area field that you do not need to
12751299
generate.
12761300
@@ -1283,8 +1307,6 @@ def _check_satisfaction(self) -> Union[str, Dict[str, Any]]:
12831307
- If the user provides information that partially answers a question, DO NOT ask for the exact same information again
12841308
- Only ask clarifying questions if critical details are genuinely missing AND the user hasn't already declined to provide them
12851309
- If a question has been asked 2+ times without a satisfactory answer, STOP asking and move to different missing information
1286-
1287-
Use structured question types (yesnoradio, radio, checkboxes, date, currency, email) whenever possible instead of open-ended text.
12881310
12891311
Respond with a JSON object in this format:
12901312
{{
@@ -1313,6 +1335,11 @@ def _check_satisfaction(self) -> Union[str, Dict[str, Any]]:
13131335
- Use email for email addresses
13141336
- All fields must have required: false
13151337
- Write questions and field labels at about a 6th-grade reading level
1338+
1339+
Use structured question types (yesnoradio, radio, checkboxes, date, currency, email)
1340+
as much as possible and whenever presenting multiple options. Only use an open-ended question when absolutely necessary.
1341+
You can direct the user to provide additional context in the open-ended text area that will always be present if an "other"
1342+
option is likely to be needed.
13161343
"""
13171344

13181345
# Build message thread
@@ -1340,6 +1367,8 @@ def _check_satisfaction(self) -> Union[str, Dict[str, Any]]:
13401367
messages=messages,
13411368
model=self.model,
13421369
json_mode=True,
1370+
skip_moderation=self.skip_moderation,
1371+
reasoning_effort=self.reasoning_effort,
13431372
)
13441373
assert isinstance(results, dict)
13451374

@@ -1470,6 +1499,8 @@ def provide_feedback(
14701499
return chat_completion(
14711500
messages=messages,
14721501
model=self.model,
1502+
skip_moderation=self.skip_moderation,
1503+
reasoning_effort=self.reasoning_effort,
14731504
)
14741505

14751506

@@ -1563,6 +1594,12 @@ def init(self, *pargs, **kwargs):
15631594
"tax": "problems with tax law, such as getting help with tax debt, dealing with the IRS, or getting help with tax preparation",
15641595
}
15651596

1597+
if not hasattr(self, "reasoning_effort"):
1598+
self.reasoning_effort = "low"
1599+
1600+
if not hasattr(self, "skip_moderation"):
1601+
self.skip_moderation = True
1602+
15661603
def _classify_problem_type(self):
15671604
"""Classifies the problem type based on the user's initial description."""
15681605
return classify_text(
@@ -1654,6 +1691,8 @@ def _current_qualification_status(self):
16541691
model=self.model,
16551692
max_output_tokens=self.max_output_tokens,
16561693
json_mode=True,
1694+
skip_moderation=self.skip_moderation,
1695+
reasoning_effort=self.reasoning_effort,
16571696
)
16581697

16591698
if isinstance(results, dict):

0 commit comments

Comments
 (0)