feat: Implement full problem generation

This commit marks a major milestone in the problem generator project.

Key systems include:
- Fully functional problem generation for all 23 defined concepts (Simple Interest, Compound Interest, Banker's Discount, Effective Rate, Continuous Compounding, Exact/Ordinary Simple Interest).
- Robust date handling for date-specific interest calculations.
- Improved solution presentation with accurate "Substitute Values" step and complete variable descriptions.
- Interactive problem display in `main.py`: problem statement shown first, question and solution revealed on user input.
- Added plausibility checks for rate calculations to avoid unrealistic negative rates.
- Comprehensive `README.md` update:
    - Detailed system architecture (modules, data files, Mermaid diagram).
    - List of all currently covered financial concepts.
    - Instructions for running and extending the generator.
    - Discussion of scope for future enhancements (Equation of Value, Gradients, etc.).
- Refinements to `value_sampler.py` for better formatting and handling of None values.
- Updates to `text_snippets.json` for complete variable descriptions and improved solution step phrasing.
- Updates to `value_ranges.json` for date generation parameters.
- `problem_engine.py` now systematically tests all concepts when run directly.
- Added `uv.lock` to track resolved dependencies.

The system is capable of generating a wide variety of engineering economy problems with detailed, step-by-step solutions and an interactive user experience.
This commit is contained in:
Jose Daniel G. Percy 2025-05-09 11:56:16 +08:00
parent f1d18ec989
commit f02f848ada
8 changed files with 812 additions and 299 deletions

243
README.md
View File

@ -1,2 +1,243 @@
# problem-generator
# Engineering Economy Problem Generator
This project is a Python-based system designed to procedurally generate engineering economy problems. It focuses on topics such as simple and compound interest, effective interest rates, continuous compounding, Banker's Discount, and date-specific interest calculations (exact and ordinary). The system aims to create varied and realistic word problems with step-by-step solutions, suitable for practice and learning.
## Covered Concepts
The generator currently supports problems related to the following topics (aligned with typical Engineering Economy curricula):
* **Simple Interest:**
* Calculating Future Value (F)
* Calculating Present Value (P) from Future Value
* Calculating Simple Interest Amount (I)
* Calculating Present Value (P) from Interest Amount
* Calculating Simple Interest Rate (i)
* Calculating Time Period (n)
* **Types of Simple Interest:**
* Exact Simple Interest (using actual days in month/year, including leap year considerations)
* Ordinary Simple Interest (using 30-day months, 360-day year)
* **Banker's Discount:**
* Calculating Proceeds
* Calculating Discount Rate
* Calculating Equivalent Simple Interest Rate
* **Compound Interest:**
* Calculating Future Value (F)
* Calculating Present Value (P)
* Calculating Nominal Interest Rate (r)
* Calculating Time Period (t or n)
* **Effective Rate of Interest:**
* Calculating Effective Rate (ER) given nominal rate and compounding frequency.
* **Continuous Compounding Interest:**
* Calculating Future Value (F)
* Calculating Present Value (P)
* Calculating Nominal Interest Rate (r)
* Calculating Time Period (t)
* Calculating Equivalent Simple Interest Rate for a continuously compounded rate.
## System Architecture
The problem generator is a modular system composed of several Python scripts and JSON data files that work together to produce financial problems.
```mermaid
graph TD
A[problem_engine.py] --> B(data_loader.py);
A --> C(value_sampler.py);
A --> D(date_utils.py);
A --> E(formula_evaluator.py);
A --> F(narrative_builder.py);
A --> G(solution_presenter.py);
B -- Reads & Caches --> H[financial_concepts.json];
B -- Reads & Caches --> I[value_ranges.json];
B -- Reads & Caches --> J[text_snippets.json];
B -- Reads & Caches --> K[data/names.json];
C -- Uses Constraints From --> I;
C -- Uses Date Functions From --> D;
F -- Uses Actor Names From --> K;
F -- Uses Text Templates From --> J;
G -- Uses Text Templates From --> J;
subgraph Python Modules
A
B
C
D
E
F
G
end
subgraph Data Files (JSON)
H[building_blocks/financial_concepts.json]
I[data/value_ranges.json]
J[data/text_snippets.json]
K[data/names.json]
end
```
### Python Modules:
* **`main.py`:**
* The main entry point for running the problem generator.
* Handles initialization and calls the problem generation engine to produce and display problems.
* **`problem_engine.py`:**
* The central coordinator of the problem generation process.
* Selects a financial concept, manages the flow of data between other modules (sampling values, evaluating formulas, building narrative, presenting solution), and assembles the final problem output.
* Includes logic for handling special variable types (like dates) and basic plausibility checks (e.g., for interest rate calculations).
* Caches loaded data to improve performance.
* **`data_loader.py`:**
* Responsible for loading data from the various JSON configuration files (`financial_concepts.json`, `value_ranges.json`, `text_snippets.json`, `names.json`).
* Provides functions to access cached versions of this data.
* **`value_sampler.py`:**
* Generates random numerical values for the variables in a problem (e.g., principal, interest rate, time).
* Uses constraints (min, max, precision, type) defined in `data/value_ranges.json`.
* Includes functions to format these values for display (e.g., adding currency symbols, percentage signs, thousands separators, formatting dates).
* **`date_utils.py`:**
* Provides utility functions for date-related calculations, crucial for problems involving specific time periods (e.g., exact simple interest).
* Includes functions for leap year checks, days in month/year, generating random dates and date periods, and calculating exact days between dates.
* **`formula_evaluator.py`:**
* Safely evaluates mathematical formula strings (defined in `building_blocks/financial_concepts.json`) using a restricted Python `eval()` environment.
* Takes a formula string and a context dictionary of variables and their values, then returns the calculated result.
* **`narrative_builder.py`:**
* Constructs the natural language word problem.
* Uses templates from `data/text_snippets.json` and actor names/details from `data/names.json`.
* Combines these with the sampled numerical values (formatted by `value_sampler.py`) to create a coherent and varied problem statement.
* **`solution_presenter.py`:**
* Generates a step-by-step guided solution for the problem.
* Uses `solution_step_keys` defined in `building_blocks/financial_concepts.json` to fetch corresponding solution step templates from `data/text_snippets.json`.
* Populates these templates with the specific values and intermediate calculations for the current problem, including a step showing the formula with values substituted.
### Data Files (JSON):
* **`building_blocks/financial_concepts.json`:**
* The core data file defining each type of financial problem the system can generate.
* Each "concept" includes:
* `concept_id`: A unique identifier.
* `description`: Human-readable explanation.
* `financial_topic`: The broader category (e.g., "Simple Interest").
* `target_unknown`: The variable the problem will ask to solve for.
* `variables_involved`: All variables relevant to the concept.
* `formulas`: A dictionary of formula strings (including for intermediate variables).
* `required_knowns_for_target`: Input variables needed to solve the problem.
* `narrative_hooks`: Keywords to guide narrative construction.
* `solution_step_keys`: An ordered list of keys mapping to solution step templates in `data/text_snippets.json`.
* **`data/value_ranges.json`:**
* Defines the constraints for generating random numerical values (min, max, currency, units, precision, type like integer/float).
* Includes options for compounding frequencies and date generation parameters (like `date_period_generation`).
* **`data/text_snippets.json`:**
* A rich repository of text fragments used by `narrative_builder.py` and `solution_presenter.py`.
* Contains templates for:
* Actor descriptions (persons, companies).
* Actions (loan, investment, repayment verbs).
* Time phrases, rate phrases, compounding phrases.
* Question starters and scenario framing elements.
* Detailed templates for each step of the guided solution (e.g., identifying knowns, stating formula, substituting values).
* Human-readable descriptions for variable keys.
* **`data/names.json`:**
* Provides lists of names (titles, first names, last names for persons) and company name components (prefixes, suffixes, industry types).
* Also includes lists of items for loan/investment purposes to add flavor to narratives.
## Problem Generation Flow
1. **Initialization:** `problem_engine.py` (called by `main.py`) loads and caches all necessary data from JSON files via `data_loader.py`.
2. **Concept Selection:** A financial concept is randomly chosen from `building_blocks/financial_concepts.json`.
3. **Value Sampling:**
* For each `required_knowns_for_target` in the selected concept, `problem_engine.py` instructs `value_sampler.py` to generate a value.
* `value_sampler.py` uses `data/value_ranges.json` for constraints.
* For date-related concepts (Exact/Ordinary Simple Interest), `problem_engine.py` uses `date_utils.py` to generate start/end dates and calculate the number of days, time base, etc.
* Compounding frequencies are also sampled specifically.
* Generated values are stored along with their metadata (units, precision).
* Basic plausibility checks (e.g., ensuring F > P when solving for rate) are performed, with resampling attempts if needed.
4. **Formula Evaluation (Intermediate & Final):**
* `problem_engine.py` identifies all formulas (intermediate and target) for the concept.
* `formula_evaluator.py` evaluates these formulas sequentially, using the sampled known values and any previously calculated intermediate values.
5. **Narrative Construction:**
* `narrative_builder.py` takes the selected concept, the (formatted) known values, and the target unknown variable.
* It uses templates from `data/text_snippets.json` and names from `data/names.json` to construct a word problem.
6. **Solution Generation:**
* `solution_presenter.py` uses the `solution_step_keys` from the concept and corresponding templates from `data/text_snippets.json`.
* It populates these templates with the actual problem data (knowns, intermediates, calculated answer) to create a step-by-step solution, including showing the formula with values substituted.
7. **Output:** `problem_engine.py` returns a structured dictionary containing the problem statement, the question, all variable data, the calculated answer (raw and formatted), and the list of solution steps. `main.py` then prints this information.
## Key Features
* **Data-Driven Design:** Problem types, numerical ranges, and language are defined in external JSON files, enhancing flexibility.
* **Modularity:** Components for value sampling, formula evaluation, narrative building, and solution presentation are distinct.
* **Procedural Generation:** Ensures variety in generated problems.
* **Natural Language Output:** Aims for human-readable problem statements and solutions.
* **Step-by-Step Solutions:** Provides detailed, guided solutions, including formula substitution.
* **Precision Control:** Manages internal precision for calculations and display precision for output.
* **Date Handling:** Robust utilities for date calculations for Exact and Ordinary Simple Interest.
* **Plausibility Checks:** Basic checks to avoid some unrealistic problem scenarios (e.g., large negative rates).
## Setup and Usage
This project uses `uv` for Python environment and dependency management, as indicated by `uv.lock` and `.python-version`.
1. **Environment Setup (Recommended):**
* Ensure `uv` is installed.
* Navigate to the project root directory.
* Run `uv sync` (if `pyproject.toml` lists dependencies) or `uv pip install -r requirements.txt` (if a `requirements.txt` file exists or is generated). Refer to `pyproject.toml` for actual dependencies.
2. **Running the Generator:**
* The primary way to generate a problem is by running `main.py` from the project root directory:
```bash
python3 main.py
```
* The `src/problem_engine.py` script can also be run directly (e.g., `python3 src/problem_engine.py`). Its `if __name__ == '__main__':` block is currently configured to test all available concepts.
## Extensibility
The system is designed to be extensible for adding new problem types that fit the current architectural pattern (solving for a single target unknown via a primary formula, possibly with intermediate calculations).
* **Adding New Financial Concepts (Similar Architecture):**
1. **Define Concept:** Add a new JSON object to `building_blocks/financial_concepts.json`. Specify:
* `concept_id` (unique string)
* `description` (string)
* `financial_topic` (string, e.g., "Simple Annuity")
* `target_unknown` (string, e.g., "F_annuity")
* `variables_involved` (list of strings, all variables in the formulas)
* `formulas` (dictionary: `{"variable_to_calc": "formula_string", ...}`). Ensure formulas for intermediate variables are listed before those that depend on them.
* `required_knowns_for_target` (list of strings, input variables to be sampled)
* `narrative_hooks` (list of strings, keywords for `narrative_builder.py`)
* `solution_step_keys` (list of strings, keys from `text_snippets.json -> solution_guidance`)
2. **Value Ranges:** If new variables require specific generation rules, add entries to `data/value_ranges.json`.
3. **Text Snippets:**
* Add descriptions for any new variables to `data/text_snippets.json` under `variable_descriptions`.
* Add new solution step templates to `solution_guidance` if existing ones are insufficient, and use these new keys in your concept's `solution_step_keys`.
* Add new narrative phrase templates if needed for unique storytelling.
4. **Variable Mapping:** Update `CONCEPT_VAR_TO_VALUERANGE_KEY` in `src/problem_engine.py` if new variables need mapping to `value_ranges.json` keys for sampling.
5. **Special Handling (If Any):** If the new concept requires unique logic in `problem_engine.py` (e.g., special date handling, unique plausibility checks) or `solution_presenter.py` (unique formatting for a solution step), those modules would need to be extended.
* **Adding New Names/Items:** Edit `data/names.json` to add more variety to actors or scenario items.
* **Adding New Text Snippets:** Edit `data/text_snippets.json` to add more phrasing options for narratives or solution steps.
* **Modifying Value Ranges:** Adjust `data/value_ranges.json` to change the scope of numerical values generated.
### Future Enhancements / Scope for Advanced Concepts
The current architecture is well-suited for problems that can be solved by evaluating one or more direct formulas to find a target unknown. More complex topics from engineering economy, such as:
* **Equation of Value / Discrete Payments (with algebraic unknowns):** Problems like the "Acosta Holdings Loan" or "Mr. Cruz's Car Purchase" from the reference notes, where an unknown payment (X) appears in multiple terms of an equation of value and needs to be solved for algebraically.
* **Arithmetic/Geometric Gradients:** While the "Restaurant Lease - Arithmetic Gradient" problem involves standard formulas (P/A and P/G), integrating these as a distinct, generatable concept requires defining them with their specific variables (A1, G, N) and solution steps.
* **Annuities (Ordinary, Due, Deferred, Perpetuities):** These have their own sets of formulas and common problem structures.
* **More Advanced Topics:** Depreciation, Bonds, Capital Budgeting (NPV, IRR), etc.
Implementing these advanced topics would require significant enhancements:
1. **Equation Solving:** The current `formula_evaluator.py` evaluates expressions but doesn't solve equations algebraically. This would likely require integrating a symbolic math library (like SymPy) or developing custom logic to set up and solve equations of value.
2. **Complex Concept Definitions:** `building_blocks/financial_concepts.json` would need a more sophisticated structure to define multiple cash flows, their timings, relationships (e.g., payment2 = 1.5 * payment1), and the setup of an equation rather than just direct formulas for a target.
3. **Enhanced `problem_engine.py`:** Logic to manage multiple cash flows, select focal dates, and coordinate the setup of equations for the solver.
4. **Advanced `solution_presenter.py`:** New solution step templates and logic to explain:
* Drawing cash flow diagrams (descriptively).
* Choosing a focal date.
* Bringing each cash flow to the focal date.
* Setting up the equation of value.
* Showing algebraic manipulation to solve for the unknown.
* Specific steps for gradient series components.
5. **Richer `value_sampler.py`:** To generate series of payments, gradient parameters, or multiple related loan/payment amounts.
These represent a next level of complexity beyond the current system's direct formula evaluation approach. A `docs/` folder could be created in the future to detail potential architectures for these advanced features.
## Known Issues / Potential Refinements
* **Banker's Discount - Negative Proceeds:** For the `BANKERS_DISCOUNT_SOLVE_FOR_PROCEEDS` concept, it's possible to generate scenarios where the calculated discount amount is larger than the maturity value, resulting in negative proceeds. While mathematically possible, this is often an undesirable outcome for typical practice problems. A future refinement could involve adding more specific plausibility checks in `problem_engine.py` to resample input values (like discount rate or time) to ensure positive proceeds.
* **Plausibility of Complex Scenarios:** As more complex multi-step problems are considered (like Equation of Value), ensuring the generated numbers lead to "sensible" real-world scenarios will require more sophisticated plausibility checks during value sampling.

View File

@ -1,45 +1,181 @@
{
"actors_person": ["{person_title} {person_first_name} {person_last_name}", "{person_first_name} {person_last_name}", "{person_title} {person_last_name}"],
"actors_company": ["{company_prefix} {company_suffix}", "{company_prefix} {company_industry}"],
"actions_loan_present_singular": ["borrows", "takes out a loan for", "secures financing for", "needs a loan of", "is seeking a loan of"],
"actions_loan_present_plural": ["borrow", "take out a loan for", "secure financing for", "need a loan of", "are seeking a loan of"],
"actions_loan_past_singular": ["borrowed", "took out a loan for", "secured financing for", "received a loan of"],
"actions_loan_past_plural": ["borrowed", "took out a loan for", "secured financing for", "received a loan of"],
"actions_investment_present_singular": ["invests", "deposits", "puts", "plans to invest", "wants to deposit"],
"actions_investment_present_plural": ["invest", "deposit", "put", "plan to invest", "want to deposit"],
"actions_investment_past_singular": ["invested", "deposited", "put", "made an investment of"],
"actions_investment_past_plural": ["invested", "deposited", "put", "made an investment of"],
"actions_repayment_present_singular": ["repays", "settles", "amortizes", "makes a payment on"],
"actions_repayment_present_plural": ["repay", "settle", "amortize", "make a payment on"],
"actions_repayment_past_singular": ["repaid", "settled", "amortized", "made a payment on"],
"actions_repayment_past_plural": ["repaid", "settled", "amortized", "made a payment on"],
"actions_receive_present_singular": ["receives", "obtains", "gets", "is due to receive"],
"actions_receive_present_plural": ["receive", "obtain", "get", "are due to receive"],
"actions_receive_past_singular": ["received", "obtained", "got"],
"actions_receive_past_plural": ["received", "obtained", "got"],
"actions_earn_present_singular": ["earns", "accumulates", "yields"],
"actions_earn_present_plural": ["earn", "accumulate", "yield"],
"actions_earn_past_singular": ["earned", "accumulated", "yielded"],
"actions_earn_past_plural": ["earned", "accumulated", "yielded"],
"time_phrases_duration": ["for a period of", "over", "for", "during"],
"time_phrases_point": ["at the end of", "after", "in"],
"rate_phrases": ["at a rate of", "with an interest of", "at an annual rate of", "earning interest at"],
"compounding_phrases": ["compounded {compounding_frequency_adverb}", "with {compounding_frequency_adverb} compounding"],
"purpose_phrases_loan": ["for {item_loan}", "to finance {item_loan}", "to purchase {item_loan}"],
"purpose_phrases_investment": ["in {item_investment}", "into {item_investment}", "to grow their capital through {item_investment}"],
"question_starters_what_is": ["What is the", "Determine the", "Calculate the", "Find the", "What will be the", "What was the"],
"question_starters_how_much": ["How much is the", "How much will be the", "How much was the", "How much should be"],
"question_starters_how_long": ["How long will it take for", "How many years are needed for", "What is the time period for"],
"actors_person": [
"{person_title} {person_first_name} {person_last_name}",
"{person_first_name} {person_last_name}",
"{person_title} {person_last_name}"
],
"actors_company": [
"{company_prefix} {company_suffix}",
"{company_prefix} {company_industry}"
],
"actions_loan_present_singular": [
"borrows",
"takes out a loan for",
"secures financing for",
"needs a loan of",
"is seeking a loan of"
],
"actions_loan_present_plural": [
"borrow",
"take out a loan for",
"secure financing for",
"need a loan of",
"are seeking a loan of"
],
"actions_loan_past_singular": [
"borrowed",
"took out a loan for",
"secured financing for",
"received a loan of"
],
"actions_loan_past_plural": [
"borrowed",
"took out a loan for",
"secured financing for",
"received a loan of"
],
"actions_investment_present_singular": [
"invests",
"deposits",
"puts",
"plans to invest",
"wants to deposit"
],
"actions_investment_present_plural": [
"invest",
"deposit",
"put",
"plan to invest",
"want to deposit"
],
"actions_investment_past_singular": [
"invested",
"deposited",
"put",
"made an investment of"
],
"actions_investment_past_plural": [
"invested",
"deposited",
"put",
"made an investment of"
],
"actions_repayment_present_singular": [
"repays",
"settles",
"amortizes",
"makes a payment on"
],
"actions_repayment_present_plural": [
"repay",
"settle",
"amortize",
"make a payment on"
],
"actions_repayment_past_singular": [
"repaid",
"settled",
"amortized",
"made a payment on"
],
"actions_repayment_past_plural": [
"repaid",
"settled",
"amortized",
"made a payment on"
],
"actions_receive_present_singular": [
"receives",
"obtains",
"gets",
"is due to receive"
],
"actions_receive_present_plural": [
"receive",
"obtain",
"get",
"are due to receive"
],
"actions_receive_past_singular": [
"received",
"obtained",
"got"
],
"actions_receive_past_plural": [
"received",
"obtained",
"got"
],
"actions_earn_present_singular": [
"earns",
"accumulates",
"yields"
],
"actions_earn_present_plural": [
"earn",
"accumulate",
"yield"
],
"actions_earn_past_singular": [
"earned",
"accumulated",
"yielded"
],
"actions_earn_past_plural": [
"earned",
"accumulated",
"yielded"
],
"time_phrases_duration": [
"for a period of",
"over",
"for",
"during"
],
"time_phrases_point": [
"at the end of",
"after",
"in"
],
"rate_phrases": [
"at a rate of",
"with an interest of",
"at an annual rate of",
"earning interest at"
],
"compounding_phrases": [
"compounded {compounding_frequency_adverb}",
"with {compounding_frequency_adverb} compounding"
],
"purpose_phrases_loan": [
"for {item_loan}",
"to finance {item_loan}",
"to purchase {item_loan}"
],
"purpose_phrases_investment": [
"in {item_investment}",
"into {item_investment}",
"to grow their capital through {item_investment}"
],
"question_starters_what_is": [
"What is the",
"Determine the",
"Calculate the",
"Find the",
"What will be the",
"What was the"
],
"question_starters_how_much": [
"How much is the",
"How much will be the",
"How much was the",
"How much should be"
],
"question_starters_how_long": [
"How long will it take for",
"How many years are needed for",
"What is the time period for"
],
"scenario_introductions": [
"Consider a scenario where {actor}",
"{actor} is planning to",
@ -57,11 +193,10 @@
"Therefore,",
"With these conditions,"
],
"solution_guidance": {
"identify_knowns": "First, let's identify the given values (knowns) in the problem:",
"state_formula": "The relevant formula for this problem is:",
"substitute_values": "Now, we substitute the known values into the formula:",
"state_formula": "The relevant formula for this problem is: {target_variable_lhs} = {formula_symbolic_rhs}",
"substitute_values": "Now, we substitute the known values: {target_variable_lhs} = {formula_with_values_rhs}",
"perform_calculation": "Performing the calculation:",
"intermediate_step": "The intermediate result for {step_name} is:",
"final_answer_is": "Therefore, the {unknown_variable_description} is:",
@ -69,9 +204,18 @@
"calculate_interest_rate_per_period": "Calculate the interest rate per compounding period (i): i = r / m = {nominal_rate_decimal} / {compounding_periods_per_year} = {interest_rate_per_period_decimal}.",
"calculate_total_periods": "Calculate the total number of compounding periods (n): n = t * m = {time_in_years} years * {compounding_periods_per_year} = {total_periods} periods.",
"check_leap_year": "{year} is {is_or_is_not} a leap year.",
"days_in_period": "The number of days from {start_date} to {end_date} is {number_of_days} days."
"days_in_period": "The number of days from {start_date} to {end_date} is {number_of_days} days.",
"determine_time_base_exact": "For exact simple interest, the time base is the actual number of days in the reference year ({year}), which is {days_in_year} days.",
"determine_time_base_ordinary": "For ordinary simple interest, the time base is 360 days.",
"calculate_n_time_years_fractional": "Calculate the time as a fraction of a year (t_fractional): t_fractional = number of days / time base = {n_time_days} / {time_base_days} = {n_time_years_fractional_value}.",
"identify_gradient_parameters": "Identify the parameters of the arithmetic gradient series:",
"state_formula_annuity_component": "The formula for the present worth of the base annuity component (P_A) is: P_A = A1 * (P/A, i, N) which is A1 * [((1 + i)^N - 1) / (i * (1 + i)^N)]",
"calculate_pv_annuity_component": "Calculating the present worth of the base annuity component (P_A):",
"state_formula_gradient_component": "The formula for the present worth of the arithmetic gradient component (P_G) is: P_G = G * (P/G, i, N) which is G * [(((1 + i)^N - (i * N) - 1) / (i^2 * (1 + i)^N))]",
"calculate_pv_gradient_component": "Calculating the present worth of the arithmetic gradient component (P_G):",
"state_formula_total_pv": "The total present worth (P_total) is the sum of the present worth of the annuity and gradient components: P_total = P_A + P_G",
"sum_pv_components": "Summing the present worth components:"
},
"variable_descriptions": {
"P": "principal amount",
"F": "future value",
@ -95,26 +239,23 @@
"start_date": "start date of the period",
"end_date": "end date of the period",
"time_base_days": "day count basis for the year (time base)",
"n_time_years_fractional": "time period as a fraction of a year"
"n_time_years_fractional": "time period as a fraction of a year",
"A1_base_annuity": "base annuity amount",
"G_gradient_amount": "arithmetic gradient amount",
"N_periods": "number of periods",
"P_A_component": "present worth of the annuity component",
"P_G_component": "present worth of the gradient component",
"P_gradient_series": "total present worth of the arithmetic gradient series",
"I_simple": "simple interest amount",
"F_simple": "future value with simple interest",
"F_compound": "future value with compound interest",
"t_years": "time period in years (for compounding)",
"i_simple_equivalent": "equivalent simple interest rate",
"F_maturity": "maturity value (for Banker's Discount)",
"P_proceeds": "proceeds from discounted loan",
"Db_discount_amount": "banker's discount amount",
"F_continuous": "future value with continuous compounding"
},
"solution_guidance": {
"identify_knowns": "First, let's identify the given values (knowns) in the problem:",
"state_formula": "The relevant formula for this problem is:",
"substitute_values": "Now, we substitute the known values into the formula:",
"perform_calculation": "Performing the calculation:",
"intermediate_step": "The intermediate result for {step_name} is:",
"final_answer_is": "Therefore, the {unknown_variable_description} is:",
"convert_time_to_years": "Convert the time period to years: {original_time_value} {original_time_unit} = {converted_time_value_years} years.",
"calculate_interest_rate_per_period": "Calculate the interest rate per compounding period (i): i = r / m = {nominal_rate_decimal} / {compounding_periods_per_year} = {interest_rate_per_period_decimal}.",
"calculate_total_periods": "Calculate the total number of compounding periods (n): n = t * m = {time_in_years} years * {compounding_periods_per_year} = {total_periods} periods.",
"check_leap_year": "{year} is {is_or_is_not} a leap year.",
"days_in_period": "The number of days from {start_date} to {end_date} is {number_of_days} days.",
"determine_time_base_exact": "For exact simple interest, the time base is the actual number of days in the reference year ({year}), which is {days_in_year} days.",
"determine_time_base_ordinary": "For ordinary simple interest, the time base is 360 days.",
"calculate_n_time_years_fractional": "Calculate the time as a fraction of a year (t_fractional): t_fractional = number of days / time base = {n_time_days} / {time_base_days} = {n_time_years_fractional_value}."
},
"compounding_frequency_adverbs": {
"annually": "annually",
"semi-annually": "semi-annually",

View File

@ -55,6 +55,13 @@
"internal_precision": 8,
"display_precision": 2
},
"interest_rate_per_period_effective": {
"min": 0.005,
"max": 0.05,
"unit_display": "% per period",
"internal_precision": 8,
"display_precision": 3
},
"discount_rate_bankers": {
"min": 0.05,
"max": 0.20,
@ -83,7 +90,7 @@
},
"number_of_periods_general": {
"min": 2,
"max": 30,
"max": 60,
"unit": "periods",
"integer": true
},
@ -124,7 +131,11 @@
"time_base_days_exact_ordinary": {
"description": "Time base in days for exact (365/366) or ordinary (360) interest.",
"type": "integer",
"options": [360, 365, 366],
"options": [
360,
365,
366
],
"unit": "days",
"display_precision": 0
},
@ -133,5 +144,12 @@
"type": "float",
"unit": "years",
"display_precision": 6
},
"date_period_generation": {
"description": "Parameters for generating random date periods.",
"min_days": 30,
"max_days": 730,
"base_year_start": 1990,
"base_year_end": 2030
}
}

55
main.py
View File

@ -1,6 +1,57 @@
def main():
print("Hello from problem-generator!")
import sys
import os
# Ensure the src directory is in the Python path
# This allows running main.py from the project root (e.g., python main.py)
# and having imports like 'from src import ...' work correctly.
project_root = os.path.abspath(os.path.dirname(__file__))
src_path = os.path.join(project_root, 'src')
if src_path not in sys.path:
sys.path.insert(0, src_path)
if project_root not in sys.path: # Also add project root for 'from src import ...' if main is outside src
sys.path.insert(0, project_root)
from src.problem_engine import generate_problem
from src.data_loader import get_text_snippets # For variable descriptions
# Note: problem_engine.generate_problem() handles caching of all necessary data internally.
def main():
print("Generating a financial problem via main.py...\n")
# Load text snippets for variable descriptions
# generate_problem() will load its own cache, but for descriptions here, we need it too.
# A more advanced setup might pass TEXT_SNIPPETS_DATA around or make it a singleton.
text_snippets_data = get_text_snippets()
if not text_snippets_data:
print("Error: Failed to load text snippets for main.py. Exiting.")
return
problem = generate_problem()
if "error" in problem:
print(f"Error generating problem: {problem['error']}")
else:
print(f"--- Problem ---")
print(f"Concept ID: {problem['concept_id']}")
print(f"Topic: {problem['topic']}")
print("\nProblem Statement:")
print(problem['problem_statement'])
input("\nPress Enter to see the question and solution...") # Wait for user interaction
unknown_key_desc = text_snippets_data.get("variable_descriptions", {}).get(problem['target_unknown_key'], problem['target_unknown_key'])
print(f"\nQuestion: What is the {unknown_key_desc}?")
print("\nGuided Solution:")
for i, step in enumerate(problem['solution_steps']):
# The steps from solution_presenter already have numbering.
# If not, add: print(f" {i+1}. {step}")
print(step)
print(f"\nFinal Answer ({problem['target_unknown_key']}): {problem['calculated_answer_formatted']}")
print("---------------------------------------\n")
if __name__ == "__main__":
main()

View File

@ -1,3 +1,7 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import random
import math # For direct use if not going through formula_evaluator for simple conversions
from src import data_loader
@ -36,11 +40,16 @@ CONCEPT_VAR_TO_VALUERANGE_KEY = {
"F_compound": "future_value",
"F_continuous": "future_value",
"F_maturity": "future_value", # For Banker's Discount, F is the maturity value
"F_exact_simple": "future_value",
"F_ordinary_simple": "future_value",
"I_simple": "interest_amount",
"I_exact_simple": "interest_amount",
"I_ordinary_simple": "interest_amount",
"Db_discount_amount": "interest_amount", # Or a specific "discount_amount" range
"P_proceeds": "principal", # Proceeds are a present value
"i_simple_annual": "simple_interest_rate_annual",
"i_simple_equivalent": "simple_interest_rate_annual", # Added for CONTINUOUS_COMPOUNDING_EQUIVALENT_SIMPLE_RATE
"n_time_years": "time_years", # For simple interest
"n_time_months": "time_months",
"n_time_days": "time_days",
@ -68,11 +77,67 @@ def generate_problem():
formula_context_vars = {} # Stores raw numerical values for formula evaluation, keyed by concept var name
# 1. Generate Known Values
for var_key in selected_concept.get("required_knowns_for_target", []):
# Convert required_knowns to a set for efficient checking and modification
required_knowns = set(selected_concept.get("required_knowns_for_target", []))
handled_vars = set()
# --- BEGIN DATE SPECIFIC LOGIC ---
# Check if the concept requires start_date and end_date
if "start_date" in required_knowns and "end_date" in required_knowns:
# Fetch date generation parameters from value_ranges.json, with defaults
date_period_config = VALUE_RANGES_CACHE.get("date_period_generation", {})
min_days_val = date_period_config.get("min_days", 30)
max_days_val = date_period_config.get("max_days", 730)
base_year_start_val = date_period_config.get("base_year_start", 1990)
base_year_end_val = date_period_config.get("base_year_end", 2030)
start_date_obj, end_date_obj, num_days_val = date_utils.get_random_date_period(
min_days=min_days_val,
max_days=max_days_val,
base_year_range=(base_year_start_val, base_year_end_val)
)
# Populate all_variables_data_formatted and formula_context_vars
all_variables_data_formatted["start_date"] = {'key': 'start_date', 'value': start_date_obj, 'unit': 'date', 'display_precision': None}
all_variables_data_formatted["end_date"] = {'key': 'end_date', 'value': end_date_obj, 'unit': 'date', 'display_precision': None}
all_variables_data_formatted["n_time_days"] = {'key': 'n_time_days', 'value': num_days_val, 'unit': 'days', 'display_precision': 0}
formula_context_vars["start_date"] = start_date_obj
formula_context_vars["end_date"] = end_date_obj
formula_context_vars["n_time_days"] = num_days_val
handled_vars.update(["start_date", "end_date", "n_time_days"]) # Mark these as handled
# Determine time_base_days and potentially time_base_year_for_exact based on financial_topic
time_base_days_val = 0
time_base_year_for_exact_val = None
if selected_concept["financial_topic"] == "Exact Simple Interest":
time_base_year_for_exact_val = start_date_obj.year
time_base_days_val = date_utils.days_in_year(time_base_year_for_exact_val)
all_variables_data_formatted["time_base_year_for_exact"] = {'key': 'time_base_year_for_exact', 'value': time_base_year_for_exact_val, 'unit': 'year', 'display_precision': 0}
formula_context_vars["time_base_year_for_exact"] = time_base_year_for_exact_val
# This variable is derived for solution steps, not typically in 'required_knowns_for_target'
elif selected_concept["financial_topic"] == "Ordinary Simple Interest":
time_base_days_val = 360
# Add time_base_days if it was determined (for Exact or Ordinary)
if time_base_days_val > 0:
all_variables_data_formatted["time_base_days"] = {'key': 'time_base_days', 'value': time_base_days_val, 'unit': 'days', 'display_precision': 0}
formula_context_vars["time_base_days"] = time_base_days_val
# This variable is derived for formulas/solution steps, not typically in 'required_knowns_for_target'
# --- END DATE SPECIFIC LOGIC ---
# Loop through all required knowns for the concept
for var_key in required_knowns: # Iterate over the original set of required knowns
if var_key in handled_vars:
continue # Skip if already handled by the date-specific logic
if var_key == "m_compounding_periods_per_year":
comp_freq_data = value_sampler.get_random_compounding_frequency()
if comp_freq_data:
all_variables_data_formatted[var_key] = comp_freq_data # Stores {'name': 'monthly', 'm_value': 12, ...}
all_variables_data_formatted[var_key] = comp_freq_data
formula_context_vars[var_key] = comp_freq_data["m_value"]
else:
return {"error": f"Failed to generate compounding frequency for {selected_concept['concept_id']}"}
@ -80,8 +145,7 @@ def generate_problem():
value_range_key = CONCEPT_VAR_TO_VALUERANGE_KEY.get(var_key)
if not value_range_key:
# This might be an intermediate variable that shouldn't be generated directly
# Or a variable that doesn't directly map to a single range (e.g. time in days from dates)
# For now, assume required_knowns are directly generatable or handled specifically.
# Or a variable that doesn't directly map to a single range
print(f"Warning: No value_range_key mapping for required known '{var_key}' in concept '{selected_concept['concept_id']}'. Skipping direct generation.")
continue
@ -92,6 +156,47 @@ def generate_problem():
else:
return {"error": f"Failed to generate value for '{var_key}' (mapped from '{value_range_key}') for concept '{selected_concept['concept_id']}'"}
# --- BEGIN PLAUSIBILITY CHECKS (e.g., for rate solving) ---
if selected_concept["concept_id"] in ["COMPOUND_INTEREST_SOLVE_FOR_RATE", "CONTINUOUS_COMPOUNDING_SOLVE_FOR_RATE", "SIMPLE_INTEREST_SOLVE_FOR_RATE_FROM_I"]:
# Ensure F > P to avoid negative or extremely low rates, unless P and F are very close.
# This logic assumes 'P' and 'F_compound'/'F_continuous'/'F_simple' are the relevant keys.
# For SIMPLE_INTEREST_SOLVE_FOR_RATE_FROM_I, it uses P and I_simple. We'd need a different check if I_simple could be negative.
p_key, f_key = None, None
if "P" in formula_context_vars:
p_key = "P"
if "F_compound" in formula_context_vars: f_key = "F_compound"
elif "F_continuous" in formula_context_vars: f_key = "F_continuous"
elif "F_simple" in formula_context_vars: f_key = "F_simple"
# For SIMPLE_INTEREST_SOLVE_FOR_RATE_FROM_I, F is not directly used, I_simple is.
# We assume I_simple will be positive. If I_simple is negative, rate will be negative.
if p_key and f_key:
max_resample_attempts = 5
attempt = 0
# We want F > P for a positive growth rate.
# If F is very close to P, rate will be very small, which is fine.
# If F < P, rate will be negative. Let's try to avoid large negative rates by ensuring F is not drastically smaller than P.
# A simple approach: if F < P, resample F until F > P * 0.8 (allowing for some negative rates but not extreme ones).
# Or, more simply for now, ensure F > P for typical positive rate problems.
while formula_context_vars[f_key] <= formula_context_vars[p_key] and attempt < max_resample_attempts:
print(f"Resampling {f_key} because {f_key} ({formula_context_vars[f_key]}) <= {p_key} ({formula_context_vars[p_key]}) for rate calculation.")
f_value_range_key = CONCEPT_VAR_TO_VALUERANGE_KEY.get(f_key)
if f_value_range_key:
var_data_f = value_sampler.get_value_for_variable(f_value_range_key)
if var_data_f:
all_variables_data_formatted[f_key] = var_data_f
formula_context_vars[f_key] = var_data_f["value"]
else: # Failed to resample F, break to avoid infinite loop
break
else: # No range key for F, break
break
attempt += 1
if formula_context_vars[f_key] <= formula_context_vars[p_key]:
print(f"Warning: Could not ensure {f_key} > {p_key} after {max_resample_attempts} attempts for concept {selected_concept['concept_id']}. Proceeding with current values.")
# --- END PLAUSIBILITY CHECKS ---
# 2. Handle Time Conversions (Example for Simple Interest if n_time_years is needed but months/days are used)
# This logic can be expanded. For now, assume n_time_years is directly generated if listed as required.
# If a concept *requires* n_time_years, but we want to sometimes *start* from months or days:
@ -198,21 +303,34 @@ if __name__ == '__main__':
narrative_builder._get_text_snippets_cached()
solution_presenter._get_text_snippets_cached()
# Load all concepts
all_concepts = data_loader.get_financial_concepts()
if not all_concepts:
print("Failed to load financial concepts for testing. Exiting.")
sys.exit(1)
print(f"Found {len(all_concepts)} concepts to test.\n")
original_financial_concepts_global = FINANCIAL_CONCEPTS # Save the global state if it was set
for i, concept_to_test in enumerate(all_concepts):
# Temporarily set the global FINANCIAL_CONCEPTS to only the current concept for generate_problem()
# This ensures generate_problem picks this specific concept.
FINANCIAL_CONCEPTS = [concept_to_test]
print(f"\n--- Testing Concept {i+1}/{len(all_concepts)}: {concept_to_test['concept_id']} ---")
problem = generate_problem() # generate_problem will use the modified global FINANCIAL_CONCEPTS
for i in range(3): # Generate a few problems
problem = generate_problem()
print(f"\n--- Problem {i+1} ---")
if "error" in problem:
print(f"Error generating problem: {problem['error']}")
else:
print(f"Concept ID: {problem['concept_id']}")
print(f"Topic: {problem['topic']}")
print("\nProblem Statement:")
print(problem['problem_statement'])
# print("\nKnown Values (Formatted for display):")
# for k, v_data in problem['known_values_data'].items():
# print(f" {k}: {value_sampler.format_value_for_display(v_data)}")
# Ensure TEXT_SNIPPETS_DATA is loaded for the question part
if TEXT_SNIPPETS_DATA is None: # Should have been loaded by _load_all_data_cached
_load_all_data_cached()
print(f"\nQuestion: What is the {TEXT_SNIPPETS_DATA['variable_descriptions'].get(problem['target_unknown_key'], problem['target_unknown_key'])}?")
@ -221,59 +339,7 @@ if __name__ == '__main__':
print(step)
print(f"\nFinal Answer ({problem['target_unknown_key']}): {problem['calculated_answer_formatted']}")
print("---------------------------------------\n")
print("---------------------------------------\n")
# Test a specific concept if needed for debugging
# For example, to test COMPOUND_INTEREST_SOLVE_FOR_TIME which uses math.log
# You might need to temporarily modify FINANCIAL_CONCEPTS to pick only this one.
# Or add a loop to find it.
# concepts = data_loader.get_financial_concepts()
# time_concept = next((c for c in concepts if c["concept_id"] == "COMPOUND_INTEREST_SOLVE_FOR_TIME"), None)
# if time_concept:
# FINANCIAL_CONCEPTS = data_loader.get_financial_concepts() # Reload to get all
concepts_to_test = [
"EXACT_SIMPLE_INTEREST_SOLVE_FOR_I",
"ORDINARY_SIMPLE_INTEREST_SOLVE_FOR_F"
]
all_concepts_loaded = data_loader.get_financial_concepts() # Ensure fresh load if not already cached by _load_all_data_cached
for concept_id_to_test in concepts_to_test:
specific_concept = next((c for c in all_concepts_loaded if c["concept_id"] == concept_id_to_test), None)
if specific_concept:
# Temporarily override the global FINANCIAL_CONCEPTS for this specific test run
global FINANCIAL_CONCEPTS
original_financial_concepts = FINANCIAL_CONCEPTS
FINANCIAL_CONCEPTS = [specific_concept]
print(f"\n--- Testing Specific Concept: {concept_id_to_test} ---")
problem = generate_problem()
if "error" in problem:
print(f"Error generating problem: {problem['error']}")
else:
print(f"Concept ID: {problem['concept_id']}")
print(f"Topic: {problem['topic']}")
print("\nProblem Statement:")
print(problem['problem_statement'])
print(f"\nQuestion: What is the {TEXT_SNIPPETS_DATA['variable_descriptions'].get(problem['target_unknown_key'], problem['target_unknown_key'])}?")
# print("\nKnown Values (Raw for debugging):")
# for k, v_data in problem['known_values_data'].items():
# print(f" {k}: {v_data['value']} (Type: {type(v_data['value'])})")
# print("\nAll Variables for Solution (Raw for debugging):")
# for k, v_data in problem['all_variables_for_solution'].items():
# print(f" {k}: {v_data['value']} (Type: {type(v_data['value'])})")
print("\nGuided Solution:")
for step in problem['solution_steps']:
print(step)
print(f"\nFinal Answer ({problem['target_unknown_key']}): {problem['calculated_answer_formatted']}")
print("---------------------------------------\n")
FINANCIAL_CONCEPTS = original_financial_concepts # Restore original concepts
else:
print(f"\n--- Concept {concept_id_to_test} not found for specific testing. ---")
FINANCIAL_CONCEPTS = original_financial_concepts_global # Restore original global state
print("Completed testing all concepts.")

View File

@ -1,4 +1,6 @@
import random
import re # Added for regex substitution
import decimal # Added for precise formatting of numbers in formulas
from src.data_loader import get_text_snippets
from src.value_sampler import format_value_for_display
@ -23,110 +25,101 @@ def get_snippet(key_path):
return f"{{Error: Snippet not found for {key_path}}}"
return current_level
def format_formula_for_display(formula_str, context_vars_data, target_variable):
def _format_value_for_formula(raw_value, var_data):
"""
Formats a formula string for display by substituting variable placeholders with their display values.
Example: "F = P * (1 + i * n)" with context becomes "F = Php 1,000.00 * (1 + 0.05 * 2.0 years)"
Formats a raw numerical value for substitution into a formula string.
Rates are kept as decimals. Uses a higher precision for rates,
otherwise display_precision from var_data.
"""
# This is a simplified version. A more robust version might parse the formula
# or use regex to replace only whole variable names.
# For now, simple string replacement.
unit_display_str = var_data.get("unit_display")
unit_str = var_data.get("unit")
# Sort keys by length (descending) to replace longer variable names first (e.g., "i_simple_annual" before "i")
# This is not perfect but helps in some cases.
# A better approach would be to use the actual variable names from the financial_concept.
is_rate = (isinstance(unit_display_str, str) and "%" in unit_display_str) or \
(isinstance(unit_str, str) and "%" in unit_str)
# We need the actual variable names used in the formula string, not necessarily the keys in context_vars_data.
# The financial_concept's formula string uses specific names.
# For now, let's assume formula_str uses the keys from context_vars_data directly.
# This needs to align with how formulas are defined in financial_concepts.json.
# Let's refine this: the formula string in financial_concepts.json uses specific variable names.
# The context_vars_data keys should match these.
display_formula = formula_str
# Replace variables with their formatted display values
# We need to be careful not to replace parts of words.
# Example: if 'P' is a variable, don't replace 'P' in 'Principal'.
# This is tricky with simple string replacement.
# A better way is to format the *values* that will be substituted into the formula string
# when it's presented as "Substitute: Formula_with_values".
# Let's re-think. The formula itself (symbolic) should be displayed as is.
# The "substitution" step is where values are shown.
return formula_str # For now, just return the symbolic formula. Substitution will be handled in a specific step.
if isinstance(raw_value, (float, decimal.Decimal)):
if is_rate:
# Use a higher, fixed precision for rates in formulas
# to maintain accuracy for manual calculation from the substituted formula.
# The internal_precision from value_ranges.json (e.g., 8) is for generation.
# For substitution display, 4 to 6 decimal places for the rate decimal is usually sufficient.
# Let's use 4 as a balance, as rates are often quoted to 2 decimal places as percentages.
# So, 0.123456 (12.3456%) would be 0.1235 if display_precision for % is 2,
# but for formula, we want 0.123456 or similar.
# Using 4 for the decimal: 0.0737 -> 0.0737, 0.1673 -> 0.1673
# Let's use 6 to be safer and show more of the internal value.
return f"{raw_value:.6f}"
else:
precision = var_data.get('display_precision')
if precision is not None:
return f"{raw_value:.{precision}f}"
else: # Default precision for non-rate floats if not specified
return f"{raw_value:.6f}"
elif isinstance(raw_value, int):
return str(raw_value)
else: # Fallback for other types (e.g. already a string, though less likely here)
return str(raw_value)
def generate_guided_solution(financial_concept, all_variables_data, calculated_solution_data):
"""
Generates a step-by-step guided solution.
Args:
financial_concept (dict): The financial concept definition.
all_variables_data (dict): Dict of all variable data (knowns, intermediates, and the final unknown's calculated value).
e.g., {"P": {'value': 1000, ...}, "i_simple_annual": {'value': 0.05, ...}, "F_simple": {'value': 1100, ...}}
calculated_solution_data (dict): Data for the final calculated unknown variable.
Returns:
list: A list of strings, where each string is a step in the solution.
"""
solution_steps_text = []
step_counter = 1
# Get the symbolic formula for the target unknown
target_unknown_key = financial_concept["target_unknown"]
symbolic_formula_for_target = financial_concept["formulas"].get(target_unknown_key, "Formula not defined")
# If there are multiple formulas (e.g. for intermediate steps), we might need to list them all
# or pick the primary one. For now, assume the target_unknown key in "formulas" is the main one.
symbolic_formula_rhs = financial_concept["formulas"].get(target_unknown_key, "Formula not defined")
for step_key_path in financial_concept.get("solution_step_keys", []):
step_text_template = get_snippet(step_key_path)
formatted_step_text = f"{step_counter}. {step_text_template}"
# --- Handle specific step types for dynamic content ---
if step_key_path == "solution_guidance.identify_knowns":
knowns_list_text = [f"{step_counter}. {step_text_template}"]
sub_step_counter = 0
for var_key in financial_concept.get("required_knowns_for_target", []):
if var_key in all_variables_data:
var_data = all_variables_data[var_key]
var_desc = get_snippet(f"variable_descriptions.{var_key}")
knowns_list_text.append(f" - {var_desc} ({var_key}): {format_value_for_display(var_data)}")
sub_step_counter +=1
# Add other relevant knowns if not in required_knowns_for_target but present (e.g. m_compounding_periods_per_year name)
if "m_compounding_periods_per_year" in all_variables_data and "m_compounding_periods_per_year" not in financial_concept.get("required_knowns_for_target", []):
var_data = all_variables_data["m_compounding_periods_per_year"]
var_desc = get_snippet(f"variable_descriptions.m_compounding_periods_per_year")
knowns_list_text.append(f" - {var_desc} (m): {var_data['name']} (m={var_data['value']})")
solution_steps_text.extend(knowns_list_text)
step_counter += 1
continue # Move to next main step
continue
elif step_key_path == "solution_guidance.state_formula":
# Display the main formula for the target unknown
formula_to_display = symbolic_formula_for_target
if financial_concept["target_unknown"] == "i_rate_per_period" and "COMPOUND_INTEREST_SOLVE_FOR_RATE" in financial_concept["concept_id"]: # Special case for rate
formula_to_display = "i = (F/P)^(1/n) - 1, then r = i * m" # More user friendly
elif financial_concept["target_unknown"] == "n_total_compounding_periods" and "COMPOUND_INTEREST_SOLVE_FOR_TIME" in financial_concept["concept_id"]:
formula_to_display = "n = log(F/P) / log(1+i), then t = n / m"
# For Exact/Ordinary interest, the formula might involve n_time_years_fractional
elif "n_time_years_fractional" in symbolic_formula_for_target:
# The formula in financial_concepts.json already uses n_time_years_fractional
pass # formula_to_display is already correct
formula_rhs_to_display = symbolic_formula_rhs
# Special handling for complex formulas to show a more user-friendly version
if target_unknown_key == "i_rate_per_period" and "COMPOUND_INTEREST_SOLVE_FOR_RATE" in financial_concept["concept_id"]:
formula_rhs_to_display = "(F/P)^(1/n) - 1" # r = i * m is handled as a subsequent step if needed
elif target_unknown_key == "n_total_compounding_periods" and "COMPOUND_INTEREST_SOLVE_FOR_TIME" in financial_concept["concept_id"]:
formula_rhs_to_display = "log(F/P) / log(1+i)" # t = n / m is handled as a subsequent step
formatted_step_text = formatted_step_text.replace("{formula_symbolic}", formula_to_display)
formatted_step_text = formatted_step_text.format(
target_variable_lhs=target_unknown_key,
formula_symbolic_rhs=formula_rhs_to_display
)
elif step_key_path == "solution_guidance.substitute_values":
subst_formula_str = symbolic_formula_for_target
sub_values_text = [f"{step_counter}. {step_text_template.replace('{formula_symbolic}', symbolic_formula_for_target)}"]
solution_steps_text.extend(sub_values_text)
step_counter +=1
continue
formula_with_values_rhs = symbolic_formula_rhs
# Iterate through all variables that might be in the formula
# Sort by length of key, descending, to replace longer keys first (e.g. "n_time_days" before "n")
sorted_var_keys = sorted(all_variables_data.keys(), key=len, reverse=True)
for var_key in sorted_var_keys:
if var_key in formula_with_values_rhs: # Check if key is part of formula string
var_data = all_variables_data[var_key]
# Format the raw value for formula substitution (decimal for rates, specific precision)
value_for_formula = _format_value_for_formula(var_data['value'], var_data)
# Use regex to replace whole words only
formula_with_values_rhs = re.sub(r'\b' + re.escape(var_key) + r'\b', value_for_formula, formula_with_values_rhs)
formatted_step_text = formatted_step_text.format(
target_variable_lhs=target_unknown_key,
formula_with_values_rhs=formula_with_values_rhs
)
elif step_key_path == "solution_guidance.perform_calculation":
pass
@ -143,15 +136,15 @@ def generate_guided_solution(financial_concept, all_variables_data, calculated_s
original_time_var = all_variables_data["n_time_months"]
original_time_unit = "months"
elif "n_time_days" in all_variables_data and "n_time_years" in all_variables_data and \
"start_date" not in all_variables_data: # Ensure not Exact/Ordinary context
"start_date" not in all_variables_data:
original_time_var = all_variables_data["n_time_days"]
original_time_unit = "days"
if original_time_var and "n_time_years" in all_variables_data:
formatted_step_text = formatted_step_text.format(
original_time_value=original_time_var['value'],
original_time_value=_format_value_for_formula(original_time_var['value'], original_time_var),
original_time_unit=original_time_unit,
converted_time_value_years=f"{all_variables_data['n_time_years']['value']:.4f}"
converted_time_value_years=_format_value_for_formula(all_variables_data['n_time_years']['value'], all_variables_data['n_time_years'])
)
else:
if (financial_concept["financial_topic"] == "Simple Interest" and \
@ -165,9 +158,9 @@ def generate_guided_solution(financial_concept, all_variables_data, calculated_s
"m_compounding_periods_per_year" in all_variables_data and \
"i_rate_per_period" in all_variables_data:
formatted_step_text = formatted_step_text.format(
nominal_rate_decimal=f"{all_variables_data['r_nominal_annual']['value']:.4f}",
nominal_rate_decimal=_format_value_for_formula(all_variables_data['r_nominal_annual']['value'], all_variables_data['r_nominal_annual']),
compounding_periods_per_year=all_variables_data['m_compounding_periods_per_year']['value'],
interest_rate_per_period_decimal=f"{all_variables_data['i_rate_per_period']['value']:.6f}"
interest_rate_per_period_decimal=_format_value_for_formula(all_variables_data['i_rate_per_period']['value'], all_variables_data['i_rate_per_period'])
)
else: continue
@ -176,9 +169,9 @@ def generate_guided_solution(financial_concept, all_variables_data, calculated_s
"m_compounding_periods_per_year" in all_variables_data and \
"n_total_compounding_periods" in all_variables_data:
formatted_step_text = formatted_step_text.format(
time_in_years=all_variables_data['t_years']['value'],
time_in_years=_format_value_for_formula(all_variables_data['t_years']['value'], all_variables_data['t_years']),
compounding_periods_per_year=all_variables_data['m_compounding_periods_per_year']['value'],
total_periods=all_variables_data['n_total_compounding_periods']['value']
total_periods=_format_value_for_formula(all_variables_data['n_total_compounding_periods']['value'], all_variables_data['n_total_compounding_periods'])
)
else: continue
@ -231,36 +224,39 @@ def generate_guided_solution(financial_concept, all_variables_data, calculated_s
formatted_step_text = formatted_step_text.format(
n_time_days=all_variables_data['n_time_days']['value'],
time_base_days=all_variables_data['time_base_days']['value'],
n_time_years_fractional_value=f"{all_variables_data['n_time_years_fractional']['value']:.6f}"
n_time_years_fractional_value=_format_value_for_formula(all_variables_data['n_time_years_fractional']['value'], all_variables_data['n_time_years_fractional'])
)
else: continue
elif step_key_path == "solution_guidance.intermediate_step":
# This needs to be more robust or specific step keys should be used.
intermediate_var_name = None
intermediate_var_data = None
if "Db_discount_amount" in all_variables_data and "BANKERS_DISCOUNT" in financial_concept["concept_id"]:
intermediate_var_name = "Db_discount_amount"
intermediate_var_desc = get_snippet(f"variable_descriptions.{intermediate_var_name}")
formatted_step_text = formatted_step_text.format(step_name=intermediate_var_desc) + f" {format_value_for_display(all_variables_data[intermediate_var_name])}"
elif "i_rate_per_period" in all_variables_data and "COMPOUND_INTEREST_SOLVE_FOR_RATE" in financial_concept["concept_id"] and target_unknown_key == "r_nominal_annual":
intermediate_var_name = "i_rate_per_period"
intermediate_var_desc = get_snippet(f"variable_descriptions.{intermediate_var_name}")
formatted_step_text = formatted_step_text.format(step_name=intermediate_var_desc) + f" {all_variables_data[intermediate_var_name]['value']:.6f}"
elif "n_total_compounding_periods" in all_variables_data and "COMPOUND_INTEREST_SOLVE_FOR_TIME" in financial_concept["concept_id"] and target_unknown_key == "t_years":
intermediate_var_name = "n_total_compounding_periods"
intermediate_var_desc = get_snippet(f"variable_descriptions.{intermediate_var_name}")
formatted_step_text = formatted_step_text.format(step_name=intermediate_var_desc) + f" {all_variables_data[intermediate_var_name]['value']:.4f} periods"
# For Exact/Ordinary interest, n_time_years_fractional is an intermediate step before the main formula
elif "n_time_years_fractional" in all_variables_data and \
financial_concept["financial_topic"] in ["Exact Simple Interest", "Ordinary Simple Interest"] and \
step_key_path == "solution_guidance.intermediate_step": # This condition might be too generic
# The specific step "calculate_n_time_years_fractional" should handle this.
# If "intermediate_step" is used generically for this, it needs better targeting.
# For now, assume "calculate_n_time_years_fractional" is the explicit step.
# This generic "intermediate_step" might not be needed for these concepts if steps are explicit.
continue # Let specific handlers deal with it.
if intermediate_var_name and intermediate_var_name in all_variables_data:
intermediate_var_data = all_variables_data[intermediate_var_name]
intermediate_var_desc = get_snippet(f"variable_descriptions.{intermediate_var_name}")
# For i_rate_per_period, we want to show more precision and not as percent here
if intermediate_var_name == "i_rate_per_period":
display_val = _format_value_for_formula(intermediate_var_data['value'], intermediate_var_data)
elif intermediate_var_name == "n_total_compounding_periods":
display_val = f"{_format_value_for_formula(intermediate_var_data['value'], intermediate_var_data)} periods"
else: # General case, use standard display formatting
display_val = format_value_for_display(intermediate_var_data)
formatted_step_text = formatted_step_text.format(step_name=intermediate_var_desc) + f" {display_val}"
else:
# Fallback or skip if no clear intermediate variable is identified for this generic step
formatted_step_text = f"{step_counter}. (Intermediate calculation step)"
solution_steps_text.append(formatted_step_text)
step_counter += 1
@ -269,31 +265,33 @@ def generate_guided_solution(financial_concept, all_variables_data, calculated_s
if __name__ == '__main__':
print("Testing Solution Presenter:")
from datetime import date
# from src.value_sampler import format_value_for_display # Already imported at top level
# Mock data for testing
mock_snippets_data = {
mock_snippets_data_for_test = { # Renamed to avoid conflict if this file is imported elsewhere
"solution_guidance": {
"identify_knowns": "First, identify knowns:",
"state_formula": "The formula is: {formula_symbolic}",
"substitute_values": "Substitute values into: {formula_symbolic}",
"state_formula": "The relevant formula for this problem is: {target_variable_lhs} = {formula_symbolic_rhs}",
"substitute_values": "Now, we substitute the known values: {target_variable_lhs} = {formula_with_values_rhs}",
"perform_calculation": "Performing calculation...",
"final_answer_is": "Therefore, the {unknown_variable_description} is:",
"convert_time_to_years": "Convert time: {original_time_value} {original_time_unit} = {converted_time_value_years} years.",
"calculate_interest_rate_per_period": "i = r/m = {nominal_rate_decimal}/{compounding_periods_per_year} = {interest_rate_per_period_decimal}.",
"calculate_total_periods": "n = t*m = {time_in_years}*{compounding_periods_per_year} = {total_periods} periods.",
"intermediate_step": "Intermediate {step_name}:",
"days_in_period": "Days from {start_date} to {end_date}: {number_of_days} days.",
"calculate_interest_rate_per_period": "Calculate the interest rate per compounding period (i): i = r / m = {nominal_rate_decimal} / {compounding_periods_per_year} = {interest_rate_per_period_decimal}.",
"calculate_total_periods": "Calculate the total number of compounding periods (n): n = t * m = {time_in_years} years * {compounding_periods_per_year} = {total_periods} periods.",
"intermediate_step": "The intermediate result for {step_name} is:",
"days_in_period": "The number of days from {start_date} to {end_date} is {number_of_days} days.",
"check_leap_year": "{year} is {is_or_is_not} a leap year.",
"determine_time_base_exact": "Exact time base for {year}: {days_in_year} days.",
"determine_time_base_ordinary": "Ordinary time base: 360 days.",
"calculate_n_time_years_fractional": "t_fractional = {n_time_days}/{time_base_days} = {n_time_years_fractional_value}."
"determine_time_base_exact": "For exact simple interest, the time base is the actual number of days in the reference year ({year}), which is {days_in_year} days.",
"determine_time_base_ordinary": "For ordinary simple interest, the time base is 360 days.",
"calculate_n_time_years_fractional": "Calculate the time as a fraction of a year (t_fractional): t_fractional = number of days / time base = {n_time_days} / {time_base_days} = {n_time_years_fractional_value}."
},
"variable_descriptions": {
"P": "Principal", "F_simple": "Future Value (Simple)", "i_simple_annual": "Annual Simple Interest Rate",
"n_time_years": "Time in Years", "n_time_months": "Time in Months", "n_time_days": "Time in Days",
"F_compound": "Future Value (Compound)", "r_nominal_annual": "Nominal Annual Rate",
"m_compounding_periods_per_year": "Compounding Frequency", "t_years": "Time in Years (Compound)",
"i_rate_per_period": "Interest Rate per Period", "n_total_compounding_periods": "Total Compounding Periods",
"i_rate_per_period": "interest rate per compounding period",
"n_total_compounding_periods": "total number of compounding periods",
"Db_discount_amount": "Banker's Discount Amount",
"start_date": "Start Date", "end_date": "End Date", "time_base_days": "Time Base (Days)",
"n_time_years_fractional": "Time (Fractional Years)",
@ -301,8 +299,9 @@ if __name__ == '__main__':
"I_ordinary_simple": "Ordinary Simple Interest", "F_ordinary_simple": "Future Value (Ordinary Simple Interest)"
}
}
# Override loader for testing
_get_text_snippets_cached = lambda: mock_snippets_data
original_get_snippets = _get_text_snippets_cached # Save original
_get_text_snippets_cached = lambda: mock_snippets_data_for_test # Override for this test block
# Test Case 4: Exact Simple Interest, Solve for I
concept4 = {
@ -310,68 +309,37 @@ if __name__ == '__main__':
"financial_topic": "Exact Simple Interest",
"target_unknown": "I_exact_simple",
"formulas": {
"n_time_years_fractional": "n_time_days / time_base_days",
"I_exact_simple": "P * i_simple_annual * n_time_years_fractional"
"n_time_years_fractional": "n_time_days / time_base_days", # Intermediate
"I_exact_simple": "P * i_simple_annual * n_time_years_fractional" # Target
},
"required_knowns_for_target": ["P", "i_simple_annual", "start_date", "end_date"],
# Note: n_time_days, time_base_days, time_base_year_for_exact are derived by problem_engine
# n_time_years_fractional is an intermediate calculation based on the above formula
"solution_step_keys": [
"solution_guidance.identify_knowns",
"solution_guidance.days_in_period",
"solution_guidance.check_leap_year", # Refers to time_base_year_for_exact
"solution_guidance.check_leap_year",
"solution_guidance.determine_time_base_exact",
"solution_guidance.calculate_n_time_years_fractional",
"solution_guidance.state_formula",
"solution_guidance.substitute_values",
"solution_guidance.calculate_n_time_years_fractional", # This step calculates n_time_years_fractional
"solution_guidance.state_formula", # Shows I_exact_simple = P * i_simple_annual * n_time_years_fractional
"solution_guidance.substitute_values", # Shows I_exact_simple = 5000.00 * 0.05 * 0.495890
"solution_guidance.perform_calculation",
"solution_guidance.final_answer_is"
]
}
all_vars4 = {
"P": {'key': 'P', 'value': 5000.0, 'currency': 'Php', 'display_precision': 2},
"i_simple_annual": {'key': 'i_simple_annual', 'value': 0.05, 'unit_display': '% p.a.', 'display_precision': 2},
"i_simple_annual": {'key': 'i_simple_annual', 'value': 0.05, 'unit_display': '% p.a.', 'display_precision': 4}, # Rate, use 4 for decimal 0.0500
"start_date": {'key': 'start_date', 'value': date(2023, 1, 15), 'unit': 'date'},
"end_date": {'key': 'end_date', 'value': date(2023, 7, 15), 'unit': 'date'},
"n_time_days": {'key': 'n_time_days', 'value': 181, 'unit': 'days', 'display_precision': 0}, # Calculated by date_utils
"time_base_year_for_exact": {'key': 'time_base_year_for_exact', 'value': 2023}, # Set by problem_engine
"time_base_days": {'key': 'time_base_days', 'value': 365, 'unit': 'days', 'display_precision': 0}, # Calculated by problem_engine
"n_time_years_fractional": {'key': 'n_time_years_fractional', 'value': 181/365, 'display_precision': 6}, # Calculated
"I_exact_simple": {'key': 'I_exact_simple', 'value': 5000 * 0.05 * (181/365), 'currency': 'Php', 'display_precision': 2}
"n_time_days": {'key': 'n_time_days', 'value': 181, 'unit': 'days', 'display_precision': 0},
"time_base_year_for_exact": {'key': 'time_base_year_for_exact', 'value': 2023, 'display_precision': 0},
"time_base_days": {'key': 'time_base_days', 'value': 365, 'unit': 'days', 'display_precision': 0},
"n_time_years_fractional": {'key': 'n_time_years_fractional', 'value': 181/365, 'display_precision': 6}, # This is calculated
"I_exact_simple": {'key': 'I_exact_simple', 'value': 5000 * 0.05 * (181/365), 'currency': 'Php', 'display_precision': 2} # Final answer
}
solution4_text = generate_guided_solution(concept4, all_vars4, all_vars4["I_exact_simple"])
print("\n--- Test Case 4: Exact Simple Interest (I) ---")
for step in solution4_text: print(step)
# Test Case 5: Ordinary Simple Interest, Solve for F
concept5 = {
"concept_id": "ORDINARY_SIMPLE_INTEREST_SOLVE_FOR_F",
"financial_topic": "Ordinary Simple Interest",
"target_unknown": "F_ordinary_simple",
"formulas": {
"n_time_years_fractional": "n_time_days / time_base_days",
"F_ordinary_simple": "P * (1 + i_simple_annual * n_time_years_fractional)"
},
"required_knowns_for_target": ["P", "i_simple_annual", "start_date", "end_date"],
"solution_step_keys": [
"solution_guidance.identify_knowns",
"solution_guidance.days_in_period",
"solution_guidance.determine_time_base_ordinary", # No check_leap_year for ordinary
"solution_guidance.calculate_n_time_years_fractional",
"solution_guidance.state_formula",
"solution_guidance.substitute_values",
"solution_guidance.perform_calculation",
"solution_guidance.final_answer_is"
]
}
all_vars5 = {
"P": {'key': 'P', 'value': 10000.0, 'currency': 'Php', 'display_precision': 2},
"i_simple_annual": {'key': 'i_simple_annual', 'value': 0.12, 'unit_display': '% p.a.', 'display_precision': 2},
"start_date": {'key': 'start_date', 'value': date(2024, 3, 1), 'unit': 'date'}, # 2024 is a leap year
"end_date": {'key': 'end_date', 'value': date(2024, 6, 29), 'unit': 'date'}, # 120 days
"n_time_days": {'key': 'n_time_days', 'value': 120, 'unit': 'days', 'display_precision': 0},
"time_base_days": {'key': 'time_base_days', 'value': 360, 'unit': 'days', 'display_precision': 0},
"n_time_years_fractional": {'key': 'n_time_years_fractional', 'value': 120/360, 'display_precision': 6},
"F_ordinary_simple": {'key': 'F_ordinary_simple', 'value': 10000 * (1 + 0.12 * (120/360)), 'currency': 'Php', 'display_precision': 2}
}
solution5_text = generate_guided_solution(concept5, all_vars5, all_vars5["F_ordinary_simple"])
print("\n--- Test Case 5: Ordinary Simple Interest (F) ---")
for step in solution5_text: print(step)
_get_text_snippets_cached = original_get_snippets # Restore original

View File

@ -1,6 +1,8 @@
import random
import decimal
import datetime # Added for type checking
from src.data_loader import get_value_ranges
from src import date_utils # Added for date formatting
# Cache loaded data to avoid repeated file I/O
VALUE_RANGES = None
@ -84,16 +86,30 @@ def format_value_for_display(value_data):
Example input: {'key': 'simple_interest_rate_annual', 'value': 0.12, 'unit_display': '% per annum', 'display_precision': 2}
Example output: "12.00% per annum"
"""
# --- BEGIN DEBUG PRINT ---
# print(f"DEBUG: format_value_for_display received value_data: {value_data}")
# --- END DEBUG PRINT ---
if not isinstance(value_data, dict) or 'value' not in value_data:
# print(f"DEBUG: Fallback str(value_data): {str(value_data)}")
return str(value_data) # Fallback
val = value_data['value']
display_precision = value_data.get('display_precision')
# --- BEGIN DEBUG PRINT ---
# print(f"DEBUG: val: {val}, display_precision: {display_precision}, type(val): {type(val)}")
# --- END DEBUG PRINT ---
# Handle date objects first
if isinstance(val, datetime.date):
# print(f"DEBUG: Path: Date. Result: {date_utils.format_date_for_display(val)}")
return date_utils.format_date_for_display(val)
# Format number with precision
if display_precision is not None and isinstance(val, (float, decimal.Decimal)):
# Check if it's a rate to be displayed as percentage
if "unit_display" in value_data and "%" in value_data["unit_display"]:
if value_data.get("unit_display") and "%" in value_data["unit_display"]: # Check if unit_display exists and is not None
formatted_num = f"{val * 100:.{display_precision}f}"
else:
formatted_num = f"{val:.{display_precision}f}"
@ -111,13 +127,17 @@ def format_value_for_display(value_data):
formatted_num = str(val)
if "currency" in value_data:
if "currency" in value_data and value_data["currency"] is not None: # Ensure currency is not None
# print(f"DEBUG: Path: Currency. Result: {value_data['currency']} {formatted_num}")
return f"{value_data['currency']} {formatted_num}"
elif "unit_display" in value_data: # e.g., "% per annum"
return f"{formatted_num}{value_data['unit_display']}" # Assumes % is part of unit_display or handled by *100
elif "unit" in value_data: # e.g., "years"
elif "unit_display" in value_data and value_data["unit_display"] is not None: # Ensure unit_display is not None
# print(f"DEBUG: Path: Unit Display. Result: {formatted_num}{value_data['unit_display']}")
return f"{formatted_num}{value_data['unit_display']}"
elif "unit" in value_data and value_data["unit"] is not None: # Ensure unit is not None
# print(f"DEBUG: Path: Unit. Result: {formatted_num} {value_data['unit']}")
return f"{formatted_num} {value_data['unit']}"
else:
# print(f"DEBUG: Path: Formatted Num Only. Result: {formatted_num}")
return formatted_num

8
uv.lock generated Normal file
View File

@ -0,0 +1,8 @@
version = 1
revision = 2
requires-python = ">=3.9"
[[package]]
name = "problem-generator"
version = "0.1.0"
source = { virtual = "." }