Build a Validated Stock Research Tool
Build a local AI tool that validates stock research before publishing reports.
Introduction
30 Second Summary
Stock reports can look convincing even when their numbers have no traceable source. That polished confidence makes weak claims easy to miss.
In this project, you will build a local stock research assistant that publishes a report only after every programmed check passes. The pipeline keeps deterministic calculations separate from local AI interpretation.
What You'll Build
Entering a stock ticker produces a validated Markdown report whose claims point back to traceable evidence.
By the end of this project, you'll have:
- A traceable evidence bundle saved to evidence.json. Each metric carries an evidence ID for traceability.
- A visible unsafe draft containing 999 plus E999. You can show exactly why unsupported output must be blocked.
- A fail-closed publishing gate that rejects malformed or unsupported model output. The report.md file appears only after every programmed check passes.
- Secret Mission: Add regression tests that prove valid output passes while unsupported numbers and citations fail.
Are there any prerequisites?
You need a Windows computer with Python 3.10 or newer. Visual Studio Code, Git, Ollama, and the Mistral model should already be installed.
Before We Start
Before any hands-on work begins, choose the ticker that will anchor your research. Commit to a fail-closed workflow where unsupported reports are blocked before publication.
Set Up the Reproducible Windows Project
A fail-closed report only earns trust when every run uses the same runtime. Unknown dependencies can turn a validation pass into a result nobody can reproduce.
This step gives your Visual Studio Code workspace a known Python runtime.
Pinned packages keep the evidence collector stable. A local Mistral response through Ollama confirms that your model is ready.
In this step, get ready to:
- Create the Windows project workspace.
- Install the pinned packages inside an isolated environment.
- Confirm that the local Mistral model responds.
Create the project workspace
A Visual Studio Code workspace keeps the project files in one named folder. Its integrated PowerShell terminal starts inside that folder.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select File from the menu bar.
- Select Open Folder....
- Select Desktop in the folder dialog.
- Select New Folder.
- Create the folder by entering validated-stock-research in the name field.
The new folder is now available in the folder dialog.
- Select the validated-stock-research folder.
- Click Select Folder.
- Select Yes, I trust the authors if the Workspace Trust dialog appears.
You should see VALIDATED-STOCK-RESEARCH at the top of the Explorer sidebar. Your files now have a predictable home.
- Select Terminal from the menu bar.
- Select New Terminal.
The terminal panel should open with PowerShell. Its prompt should point to the validated-stock-research folder.
Seeing a different terminal shell?
- Select the Launch Profile dropdown in the terminal panel.
- Choose PowerShell from the profile list.
Use this if you need help choosing the correct terminal: Help me switch the Visual Studio Code integrated terminal to PowerShell on Windows.
The project requires Python 3.10 or newer because that is the minimum supported by the pinned Pydantic release.
- Inspect the installed Python runtimes by running this command:
py list
What does this command show?
The Windows Python launcher lists the runtimes available on your computer. The version shown here determines whether this project can use the pinned dependencies.
✔️ I see version 3.10 or higher
Your installed Python runtime meets the project requirement. Continue with the project environment.
ⓧ I see an older version
Your current runtime is below the project minimum. Install a supported runtime before creating the environment.
- Open the official Python guide for Windows in your browser.
- Follow the Windows installation steps for Python 3.10 or newer.
- Close Visual Studio Code after the installation completes.
- Return to the validated-stock-research folder in Visual Studio Code.
- Repeat the runtime inventory command above.
You should now see Python 3.10 or newer in the runtime list.
ⓧ Python is unavailable
The Windows Python launcher is unavailable. Complete the official Windows installation before continuing.
- Open the official Python guide for Windows in your browser.
- Follow the documented Windows installation steps.
- Close Visual Studio Code after the installation completes.
- Return to the validated-stock-research folder in Visual Studio Code.
- Repeat the runtime inventory command above.
You should now see an installed Python runtime at version 3.10 or newer.
Pin the Python environment
A virtual environment isolates this project's packages from the rest of your computer. The .venv folder stays local because anyone can rebuild it from requirements.txt.
- Select New File in the Explorer sidebar.
- Name the file requirements.txt.
- Add the pinned project dependencies by pasting this content:
ollama==0.6.3
pydantic==2.14.1
yfinance==1.7.0
Why pin these packages?
- The ollama package connects Python to the local Ollama service.
- Pydantic checks the structure of model output before publication.
- yfinance retrieves the market data used by the deterministic collector.
- Exact versions keep the validation behavior repeatable across installations.
- Save requirements.txt by pressing Ctrl+S.
- Confirm that requirements.txt appears in the Explorer sidebar with three dependency lines.
Do the package pins look different?
Check each package name for a spelling error. Confirm that every package uses two equals signs before its version.
Use this if the file still differs: Help me compare my requirements.txt file with the three required package pins.
✔️ Awesome, I've got everything!
Your requirements.txt file is saved with all three pinned dependencies.
ⓧ I'd like to double check the full code
Compare your complete requirements.txt file with this reference.
ollama==0.6.3
pydantic==2.14.1
yfinance==1.7.0
What should match?
The package names must appear in this order. Each version must match exactly so the installation remains reproducible.
- Create the local .venv folder by running this command:
python -m venv .venv
What does this command create?
Python creates an isolated interpreter with its own package directory inside .venv. This keeps the project dependencies separate from system-wide packages.
You should see a new .venv folder in the Explorer sidebar.
Missing the .venv folder?
Confirm that the terminal prompt points to the validated-stock-research folder. Check that the Python runtime inventory shows a supported version.
Use this if environment creation fails: Help me troubleshoot why python -m venv .venv did not create the folder.
- Activate the project environment in PowerShell by running this command:
.venv\Scripts\Activate.ps1
What does activation change?
Activation points Python package commands at the project environment. The prompt gains a (.venv) prefix so you can see which environment is active.
✔️ My prompt starts with (.venv)
The isolated environment is active in PowerShell. Package installation now targets this project.
ⓧ PowerShell blocks the activation script
PowerShell can restrict local activation scripts. The documented current-user policy allows locally created scripts while retaining warnings for downloaded scripts.
- Apply the current-user execution policy by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
What does this policy change?
The command changes the execution policy for your Windows user account. It allows the local virtual-environment activation script to run.
- Retry the environment activation by running this command:
.venv\Scripts\Activate.ps1
What should change now?
PowerShell activates the project environment. The terminal prompt should now begin with (.venv).
Still blocked from activating?
Confirm that the policy command uses the current-user scope. Confirm that the terminal is running PowerShell inside the project folder.
Use this if activation remains blocked: Help me diagnose why PowerShell still blocks Activate.ps1.
- Press Ctrl+Shift+P to open the Command Palette.
- Run Python: Select Interpreter from the Command Palette.
You should see a list of Python environments detected for the workspace.
- Select the interpreter inside .venv.
- Install the pinned dependencies into the active environment by running this command:
py -m pip install -r requirements.txt
What does this installation use?
Pip reads each exact package pin from requirements.txt. The active environment receives those versions without changing packages used by other projects.
The terminal should show download and installation progress. It should finish without reporting an installation failure.
Did the package installation fail?
Confirm that the prompt begins with (.venv). Compare requirements.txt with the full-file reference above.
Use this if the install still fails: Help me troubleshoot my pinned package installation in the Windows virtual environment.
Confirm the local model
The Python packages prepare the application layer. The local model must also be present in Ollama before later code can request an interpretation.
- List the models stored by Ollama by running this command:
ollama ls
What does this command check?
Ollama lists the models available on your computer. A mistral entry means later requests can stay local.
✔️ I see mistral
The required model is already stored locally. You can test its response next.
ⓧ Mistral is missing
The model needs to be restored before the local test. This download can take several minutes because it transfers the model files.
- Download the confirmed Mistral model by running this command:
ollama pull mistral
What does this command retrieve?
Ollama downloads the model identified as mistral to local storage. The terminal displays progress while the files arrive.
- Repeat the model-list command above after the download completes.
You should now see mistral in the local model list.
Did the model download stop?
Keep Ollama running on Windows. Confirm that your internet connection remains available during the download.
Use this if the download still fails: Help me troubleshoot why Ollama cannot pull the mistral model on Windows.
Before you start the model, do you expect the response to come from a remote account or from the model stored on your computer?
- Start an interactive local Mistral session by running this command:
ollama run mistral
What does this test prove?
Ollama loads Mistral for an interactive session on your computer. A generated reply proves that the local inference path is responding.
- Enter Hello at the model prompt.
You should see a generated reply beneath your prompt. That response confirms local Mistral inference is working.
- Press Ctrl+C to leave the interactive session.
Did Mistral fail to respond?
Confirm that ollama ls includes mistral. Keep the Ollama application running while you retry the model session.
Use this if the session still fails: Help me troubleshoot why my local mistral model does not respond through Ollama.
Before you run the final checks, which exact package versions should appear beside the three pinned dependencies?
- Verify the active environment's installed packages by running this command:
py -m pip list
What does the package list verify?
The list reveals the package versions installed for the active Python environment. It provides the reproducibility check for the dependency layer.
You should see ollama at version 0.6.3. You should also see pydantic at version 2.14.1 and yfinance at version 1.7.0.
Are the pinned versions missing?
Check that the prompt begins with (.venv). Reinstall from the saved requirements.txt file if the active environment lacks a pinned package.
Use this if the versions still differ: Help me find why pip list does not show my pinned package versions.
- Verify the local model inventory one final time by running this command:
ollama ls
What completes the setup check?
The final model inventory confirms that Ollama still has Mistral available after the response test. This closes the setup check across Python dependencies and local inference.
You should see mistral in the output. Your reproducible Windows environment is ready.
You have locked down the runtime, the dependency versions, and the local model. Next, you'll collect traceable stock evidence without replacing missing values.
Collect Deterministic Evidence
Your reproducible Windows workspace is ready. You can now turn that controlled setup into a traceable evidence package.
The local model needs bounded evidence before it can interpret a company. This collector keeps missing values visible instead of silently replacing them with zero.
Why Python owns the numbers
Python collects the source values. It also performs every financial calculation.
The local model receives finished evidence records later. This keeps arithmetic deterministic while preserving room for cautious interpretation.
In this step, get ready to:
- Create reusable helpers for traceable evidence records.
- Collect adjusted prices plus annual financial statements with yfinance.
- Save supported calculations with explicit missing-data disclosures.
Build the path and value helpers
Each evidence record needs a stable ID. It also needs a source field plus a reporting period.
The first helpers prepare safe output paths. They also normalize values without inventing replacements.
- Create collect.py inside the validated-stock-research folder using the new-file control in the Visual Studio Code Explorer.
- Add the imports plus the output location by pasting this code into collect.py:
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
OUTPUT_ROOT = Path("outputs")
What do these imports provide?
- The standard-library imports handle command arguments, JSON serialization, finite-number checks, UTC timestamps, and file paths.
- The yf alias provides access to ticker data.
- The OUTPUT_ROOT constant keeps every generated artifact under outputs.
- Save collect.py.
- Confirm the Visual Studio Code Explorer lists collect.py beside requirements.txt.
Is the file in the wrong folder?
- Move collect.py beside requirements.txt if the Explorer shows it outside validated-stock-research.
Ask for help locating the file with Visual Studio Code: Why is collect.py outside my validated-stock-research folder?
- Add the safe ticker-folder helper below OUTPUT_ROOT by pasting this code:
def output_dir(symbol: str) -> Path:
safe_symbol = "".join(
character if character.isalnum() or character in "._-" else "_"
for character in symbol.upper()
)
return OUTPUT_ROOT / safe_symbol
How does the output path stay safe?
The helper converts the ticker to uppercase. Unsupported path characters become underscores.
A ticker such as MSFT therefore maps to outputs/MSFT.
- Save collect.py.
- Confirm the Visual Studio Code Outline lists output_dir.
Is output_dir missing from the Outline?
- Check that def output_dir starts at the left edge of the editor.
- Check that every line inside the function uses four spaces of indentation.
Get focused indentation help here: Why is my output_dir function not recognized in collect.py?
- Add the reporting-period formatter below output_dir by pasting this code:
def period_label(value: object) -> str:
if hasattr(value, "date"):
return value.date().isoformat()
return str(value)
Why normalize reporting periods?
Market data can expose date-like objects. Statement columns can expose other label types.
This helper turns either form into a readable string for the evidence file.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists period_label.
Is period_label nested accidentally?
- Align def period_label with def output_dir.
- Keep both return lines inside period_label.
Ask for help with the function boundary: How should period_label be indented below output_dir?
- Add the finite-number check below period_label by pasting this code:
def is_finite_number(value: object) -> bool:
try:
return math.isfinite(float(value))
except (TypeError, ValueError):
return False
What counts as usable numeric data?
A usable value must convert to a finite number. Missing objects plus invalid text return False.
This check prevents invalid values from entering later calculations.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists is_finite_number.
Seeing an indentation warning?
- Place the except line directly under try.
- Indent both return lines one level inside their blocks.
Get help checking the block structure: Why does is_finite_number have an indentation error?
- Add the display-value formatter below is_finite_number by pasting this code:
def format_reported_units(value: float) -> str:
absolute = abs(value)
if absolute >= 1_000_000_000_000:
return f"{value / 1_000_000_000_000:,.2f} trillion reported units"
if absolute >= 1_000_000_000:
return f"{value / 1_000_000_000:,.2f} billion reported units"
if absolute >= 1_000_000:
return f"{value / 1_000_000:,.2f} million reported units"
if absolute >= 1_000:
return f"{value / 1_000:,.2f} thousand reported units"
return f"{value:,.2f} reported units"
Why use reported units?
Financial statements can use different reporting currencies. The formatter avoids assuming that every value represents US dollars.
Large values still receive readable scale labels such as million or billion reported units.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists format_reported_units.
Is the formatter showing a syntax warning?
- Check that every formatted string starts with f".
- Check that each formatted string ends with a matching double quote.
Ask for help checking the formatted strings: Which quote or brace is mismatched in format_reported_units?
- Add the evidence-record builder below format_reported_units by pasting this code:
def add_evidence(
bundle: dict,
metric: str,
value: float,
display_value: str,
period: str,
source_field: str,
calculation: str | None = None,
) -> None:
record = {
"id": f"E{len(bundle['evidence']) + 1:03d}",
"metric": metric,
"value": value,
"display_value": display_value,
"period": period,
"source_field": source_field,
}
if calculation:
record["calculation"] = calculation
bundle["evidence"].append(record)
How do evidence IDs work?
The record count creates sequential IDs such as E001. Each ID points to one metric plus its source context.
Calculated metrics gain a calculation field. Direct source values omit that optional field.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists add_evidence.
Is add_evidence missing?
- Check that the closing parenthesis after calculation aligns with def add_evidence.
- Check that bundle["evidence"].append(record) remains inside the function.
Get help checking the record builder: Why is add_evidence not appearing as a complete function?
Collect prices and save the first bundle
Annual statements expose rows across reporting periods. The next helpers select usable values while recording unavailable data explicitly.
- Add the statement-row selector below add_evidence by pasting this code:
def latest_points(frame: object, row_name: str) -> list[tuple[object, float]]:
if frame is None or frame.empty or row_name not in frame.index:
return []
row = frame.loc[row_name].dropna()
points = [
(column, float(value))
for column, value in row.items()
if is_finite_number(value)
]
return sorted(points, key=lambda item: period_label(item[0]), reverse=True)
How are statement values selected?
The helper returns an empty list when a statement row is unavailable. Usable values are sorted with the newest reporting period first.
The empty list gives later code a clear missing-data signal.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists latest_points.
Is latest_points underlined?
- Check that the list-comprehension lines remain inside the square brackets.
- Check that reverse=True remains inside the final sorted call.
Ask for help with the comprehension: Why does latest_points have a bracket or indentation error?
- Add the statement loader below latest_points by pasting this code:
def load_statement(bundle: dict, label: str, loader: object) -> object | None:
try:
frame = loader()
except Exception as error:
bundle["missing"].append(
f"{label} unavailable because {type(error).__name__} was raised."
)
return None
if frame is None or frame.empty:
bundle["missing"].append(f"{label} returned no rows.")
return None
return frame
How does the loader fail safely?
Retrieval failures become descriptions in missing. Empty statements receive their own disclosure.
The collector can continue without turning an unavailable statement into a false zero.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists load_statement.
Is the loader block malformed?
- Align except Exception as error with try.
- Keep the empty-frame check outside the exception block.
Get help reviewing the control flow: How should the try block and empty-frame check be arranged in load_statement?
- Add the latest-metric helper below load_statement by pasting this code:
def add_latest_statement_metric(
bundle: dict,
frame: object,
row_name: str,
metric: str,
source_field: str,
) -> list[tuple[object, float]]:
points = latest_points(frame, row_name)
if not points:
bundle["missing"].append(f"{metric} is unavailable.")
return []
period, value = points[0]
add_evidence(
bundle,
metric,
value,
format_reported_units(value),
period_label(period),
source_field,
)
return points
What does this helper preserve?
The helper preserves all usable period-value pairs for later calculations. It adds only the latest value as direct evidence.
A missing row produces a disclosure instead of an evidence record.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists add_latest_statement_metric.
Is the helper nested under load_statement?
- Align def add_latest_statement_metric with the other top-level function definitions.
- Keep return points inside the new helper.
Ask for help separating the functions: How do I move add_latest_statement_metric out of load_statement?
- Add the initial evidence-bundle structure below add_latest_statement_metric by pasting this code:
def collect_evidence(symbol: str) -> dict:
symbol = symbol.strip().upper()
bundle = {
"symbol": symbol,
"generated_at": datetime.now(timezone.utc).isoformat(),
"source": {
"library": "yfinance 1.7.0",
"upstream": "Yahoo Finance",
"scope": "Personal research and educational use",
},
"evidence": [],
"missing": [],
"conflicts": [],
"quality_notes": [
"Independent-source conflicts are not checked in this single-source project.",
"AI citations prove that an evidence ID exists, not that an interpretation is objectively correct.",
],
}
ticker = yf.Ticker(symbol)
What is the evidence bundle?
The bundle separates evidence from missing-data disclosures. It also reserves space for conflicts plus quality notes.
The source metadata makes the single-source boundary visible to anyone reviewing the file.
- Save collect.py.
- Confirm the Visual Studio Code Outline now lists collect_evidence.
Is collect_evidence missing from the Outline?
- Check that every dictionary key has a matching value.
- Check that the closing brace for bundle aligns with its assignment.
Get help checking the dictionary structure: Which bracket is unmatched in my collect_evidence bundle?
- Add adjusted-close collection directly below ticker = yf.Ticker(symbol) by pasting this code:
try:
history = ticker.history(period="1y", auto_adjust=True)
close = history["Close"].dropna()
if close.empty:
bundle["missing"].append("Adjusted closing-price history returned no rows.")
else:
latest_close = float(close.iloc[-1])
latest_period = period_label(close.index[-1])
add_evidence(
bundle,
"Latest adjusted close",
latest_close,
f"{latest_close:,.2f} quote units",
latest_period,
"Ticker.history(period='1y', auto_adjust=True) -> Close",
)
except Exception as error:
bundle["missing"].append(
f"Price history unavailable because {type(error).__name__} was raised."
)
How is price evidence bounded?
The history request covers one available year with explicit price adjustment. The newest nonempty close becomes a traceable evidence record.
A retrieval problem becomes a missing-data entry. The collector continues building the bundle.
- Save collect.py.
- Confirm the new try block remains inside collect_evidence.
Is the price block outside the function?
- Indent try by four spaces under collect_evidence.
- Align except Exception as error with that try line.
Ask for help with the nested blocks: How should the price-history try block be indented inside collect_evidence?
- Add bundle serialization directly below the price-history exception block by pasting this code:
destination = output_dir(symbol)
destination.mkdir(parents=True, exist_ok=True)
evidence_path = destination / "evidence.json"
evidence_path.write_text(
json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8"
)
return bundle
How is the bundle saved?
The output directory is created when needed. The bundle is serialized as indented UTF-8 JSON.
Returning the bundle lets later application code reuse the same in-memory evidence.
- Save collect.py.
- Confirm the save block remains inside collect_evidence.
Is the save block at the wrong level?
- Indent destination = output_dir(symbol) by four spaces.
- Align return bundle with the destination assignment.
Get help checking the function ending: Where should the serialization block sit inside collect_evidence?
- Add the command-line entry point at the end of collect.py by pasting this code:
def main() -> None:
parser = argparse.ArgumentParser(description="Collect traceable stock evidence.")
parser.add_argument("ticker", nargs="?", default="MSFT")
args = parser.parse_args()
bundle = collect_evidence(args.ticker)
path = output_dir(bundle["symbol"]) / "evidence.json"
print(f"Saved {path} with {len(bundle['evidence'])} evidence records.")
if __name__ == "__main__":
main()
What does the entry point do?
The optional command-line argument selects a ticker. The default ticker is MSFT.
The final message reports the saved path plus the evidence count.
- Save collect.py.
- Confirm the Visual Studio Code Outline lists main below collect_evidence.
Is main nested inside collect_evidence?
- Align def main with the other top-level function definitions.
- Keep the final main() call indented beneath the if line.
Ask for help separating the entry point: Why is main nested inside collect_evidence?
Before you run the collector, do you expect it to create a ticker folder or write the evidence file beside the script?
- Run the first working collector from the activated PowerShell terminal with this command:
python collect.py MSFT
What should the first run prove?
You should see a saved path under outputs/MSFT. The evidence count reflects the adjusted-close record available in this first version.
This proves that collection, serialization, and ticker-specific output paths work together.
- Expand outputs in the Visual Studio Code Explorer.
- Expand MSFT to confirm that evidence.json exists.
Did the first run fail?
- Confirm the PowerShell prompt still shows the active .venv environment.
- Check that collect.py is beside requirements.txt.
- Compare the line named by Python with the latest code block you added.
Get help with the exact failure here: Why did python collect.py MSFT fail in my activated Windows project?
Add statements and compatible calculations
Price history covers market movement. Annual statements add revenue, profit, debt, and cash-flow evidence.
Calculations run only when their required source values exist. Period-sensitive calculations also require compatible reporting periods.
- Find destination = output_dir(symbol) inside collect_evidence.
- Insert annual statement loading plus income metrics immediately above that line by pasting this code:
income = load_statement(
bundle,
"Annual income statement",
lambda: ticker.get_income_stmt(freq="yearly"),
)
balance = load_statement(
bundle,
"Annual balance sheet",
lambda: ticker.get_balance_sheet(freq="yearly"),
)
cash_flow = load_statement(
bundle,
"Annual cash-flow statement",
lambda: ticker.get_cash_flow(freq="yearly"),
)
revenue_points = add_latest_statement_metric(
bundle,
income,
"TotalRevenue",
"Latest total revenue",
"get_income_stmt(freq='yearly') -> TotalRevenue",
)
net_income_points = add_latest_statement_metric(
bundle,
income,
"NetIncome",
"Latest net income",
"get_income_stmt(freq='yearly') -> NetIncome",
)
What enters the income evidence?
The three loaders request yearly income, balance-sheet, and cash-flow statements. Retrieval problems flow into missing.
The income rows preserve all usable periods for later calculations. Their newest values enter the evidence list.
- Save collect.py.
- Confirm the inserted block sits above destination = output_dir(symbol).
Are statement calls outside collect_evidence?
- Indent each statement assignment by four spaces.
- Keep every lambda inside its matching load_statement call.
Ask for help with the insertion point: Where should the annual statement block go inside collect_evidence?
- Add debt plus cash-flow metrics below the net_income_points call by pasting this code:
add_latest_statement_metric(
bundle,
balance,
"TotalDebt",
"Latest total debt",
"get_balance_sheet(freq='yearly') -> TotalDebt",
)
add_latest_statement_metric(
bundle,
cash_flow,
"OperatingCashFlow",
"Latest operating cash flow",
"get_cash_flow(freq='yearly') -> OperatingCashFlow",
)
add_latest_statement_metric(
bundle,
cash_flow,
"FreeCashFlow",
"Latest free cash flow",
"get_cash_flow(freq='yearly') -> FreeCashFlow",
)
Why keep the source fields explicit?
Each metric records the originating statement method plus row identifier. A reviewer can trace the displayed value back to the requested field.
Unavailable rows receive disclosures through the shared helper.
- Save collect.py.
- Confirm the new calls remain above the output serialization block.
Is a statement row always missing?
- Compare the row identifier with the matching code block.
- Check that balance is used for debt.
- Check that cash_flow is used for both cash-flow metrics.
Get help tracing the mapping: Why is my latest statement metric being recorded as missing?
Before you rerun the collector, do you expect the evidence count to stay the same or grow?
- Run the expanded collector with this command:
python collect.py MSFT
What does the larger count show?
You should see the same saved path with a larger evidence count when annual fields are available. Missing fields appear in the file instead of becoming zero-valued evidence.
The visible count confirms that the statement helpers are feeding the bundle.
Did the evidence count stay small?
- Open outputs/MSFT/evidence.json in the Visual Studio Code Explorer.
- Read the missing entries to identify unavailable statements or rows.
- Keep genuine missing-data entries unchanged.
Ask for help interpreting the disclosures: What do the missing entries in my MSFT evidence bundle mean?
- Find destination = output_dir(symbol) again.
- Insert the adjusted-close return directly above the annual statement loading block by pasting this code inside the existing price-history else block:
if len(close) >= 2 and float(close.iloc[0]) != 0:
first_close = float(close.iloc[0])
return_percent = ((latest_close / first_close) - 1) * 100
add_evidence(
bundle,
"Available-period adjusted-close return",
return_percent,
f"{return_percent:,.2f}%",
f"{period_label(close.index[0])} to {latest_period}",
"Ticker.history(period='1y', auto_adjust=True) -> Close",
"((latest close / first close) - 1) * 100",
)
else:
bundle["missing"].append(
"Adjusted-close return needs at least two nonzero observations."
)
When is the return calculated?
The calculation requires at least two adjusted closes. Its starting value must also be nonzero.
The evidence record stores the exact formula plus the observed date range.
- Save collect.py.
- Confirm this block sits after the latest-close evidence call but before the price-history except block.
Is the return block triggering an indentation error?
- Align the new if len(close) line with latest_close.
- Keep its else line inside the price-history else block.
Get help checking the nested branches: How should the adjusted-close return block be nested inside collect_evidence?
- Find the final cash-flow metric call inside collect_evidence.
- Add annual revenue-change calculation directly below that call by pasting this code:
if len(revenue_points) >= 2 and revenue_points[1][1] != 0:
latest_period, latest_revenue = revenue_points[0]
prior_period, prior_revenue = revenue_points[1]
revenue_change = ((latest_revenue / prior_revenue) - 1) * 100
add_evidence(
bundle,
"Annual total-revenue change",
revenue_change,
f"{revenue_change:,.2f}%",
f"{period_label(prior_period)} to {period_label(latest_period)}",
"Calculated from TotalRevenue",
"((latest revenue / prior revenue) - 1) * 100",
)
else:
bundle["missing"].append(
"Annual total-revenue change needs two usable annual values."
)
When is revenue change supported?
The calculation requires two usable annual revenue values. The earlier value must be nonzero.
The resulting evidence includes both periods plus the deterministic formula.
- Save collect.py.
- Confirm the revenue-change block remains above destination = output_dir(symbol).
Is revenue change always unavailable?
- Confirm both income-metric calls assign their results to revenue_points plus net_income_points.
- Read the related missing entry when the source provides fewer than two usable annual values.
Ask for help tracing the prerequisite values: Why did my annual revenue-change calculation stay unavailable?
- Add the compatible-period margin calculation below the revenue-change block by pasting this code:
revenue_by_period = {
period_label(period): value for period, value in revenue_points
}
net_income_by_period = {
period_label(period): value for period, value in net_income_points
}
shared_periods = sorted(
set(revenue_by_period) & set(net_income_by_period), reverse=True
)
margin_added = False
for shared_period in shared_periods:
revenue = revenue_by_period[shared_period]
if revenue != 0:
margin = (net_income_by_period[shared_period] / revenue) * 100
add_evidence(
bundle,
"Net-income margin",
margin,
f"{margin:,.2f}%",
shared_period,
"Calculated from NetIncome and TotalRevenue",
"(net income / total revenue) * 100",
)
margin_added = True
break
if not margin_added:
bundle["missing"].append(
"Net-income margin needs revenue and net income for the same period."
)
Why match the reporting period?
Revenue plus net income must describe the same annual period. The calculation uses the newest shared period with nonzero revenue.
No shared usable period produces a clear missing-data disclosure.
- Save collect.py.
- Confirm the margin block sits directly above the output serialization block.
Is the margin block malformed?
- Check that each dictionary comprehension has matching braces.
- Keep break inside the nonzero-revenue branch.
- Align if not margin_added with the for loop.
Get help checking the period match: Why does my net-income margin block have a syntax or indentation error?
Before the final run, which bundle section do you expect to explain any unavailable metric?
- Run the completed collector with this command:
python collect.py MSFT
What should the completed run show?
The terminal should report outputs/MSFT/evidence.json plus a nonzero evidence count. Market values can vary with retrieval time.
That nonzero count proves the collector saved available evidence without requiring every field to exist.
- Open outputs/MSFT/evidence.json from the Visual Studio Code Explorer.
- Confirm the top-level object contains evidence.
- Confirm the top-level object contains missing.
- Confirm the top-level object contains quality_notes.
- Inspect an evidence record to find its ID plus its period and source field.
You have a working evidence boundary. Available values are traceable while unavailable values remain visible.
Is the final evidence count zero?
- Confirm your computer has internet access for market-data retrieval.
- Open outputs/MSFT/evidence.json to read the recorded retrieval failures.
- Run the command again after restoring the connection.
Ask for help using the disclosures: Why does my evidence bundle contain zero records?
✔️ Awesome, I've got everything!
Great work. Your collector now saves traceable evidence plus explicit missing-data disclosures.
ⓧ I'd like to double check the full code
Compare your saved collect.py with this complete reference:
import argparse
import json
import math
from datetime import datetime, timezone
from pathlib import Path
import yfinance as yf
OUTPUT_ROOT = Path("outputs")
def output_dir(symbol: str) -> Path:
safe_symbol = "".join(
character if character.isalnum() or character in "._-" else "_"
for character in symbol.upper()
)
return OUTPUT_ROOT / safe_symbol
def period_label(value: object) -> str:
if hasattr(value, "date"):
return value.date().isoformat()
return str(value)
def is_finite_number(value: object) -> bool:
try:
return math.isfinite(float(value))
except (TypeError, ValueError):
return False
def format_reported_units(value: float) -> str:
absolute = abs(value)
if absolute >= 1_000_000_000_000:
return f"{value / 1_000_000_000_000:,.2f} trillion reported units"
if absolute >= 1_000_000_000:
return f"{value / 1_000_000_000:,.2f} billion reported units"
if absolute >= 1_000_000:
return f"{value / 1_000_000:,.2f} million reported units"
if absolute >= 1_000:
return f"{value / 1_000:,.2f} thousand reported units"
return f"{value:,.2f} reported units"
def add_evidence(
bundle: dict,
metric: str,
value: float,
display_value: str,
period: str,
source_field: str,
calculation: str | None = None,
) -> None:
record = {
"id": f"E{len(bundle['evidence']) + 1:03d}",
"metric": metric,
"value": value,
"display_value": display_value,
"period": period,
"source_field": source_field,
}
if calculation:
record["calculation"] = calculation
bundle["evidence"].append(record)
def latest_points(frame: object, row_name: str) -> list[tuple[object, float]]:
if frame is None or frame.empty or row_name not in frame.index:
return []
row = frame.loc[row_name].dropna()
points = [
(column, float(value))
for column, value in row.items()
if is_finite_number(value)
]
return sorted(points, key=lambda item: period_label(item[0]), reverse=True)
def load_statement(bundle: dict, label: str, loader: object) -> object | None:
try:
frame = loader()
except Exception as error:
bundle["missing"].append(
f"{label} unavailable because {type(error).__name__} was raised."
)
return None
if frame is None or frame.empty:
bundle["missing"].append(f"{label} returned no rows.")
return None
return frame
def add_latest_statement_metric(
bundle: dict,
frame: object,
row_name: str,
metric: str,
source_field: str,
) -> list[tuple[object, float]]:
points = latest_points(frame, row_name)
if not points:
bundle["missing"].append(f"{metric} is unavailable.")
return []
period, value = points[0]
add_evidence(
bundle,
metric,
value,
format_reported_units(value),
period_label(period),
source_field,
)
return points
def collect_evidence(symbol: str) -> dict:
symbol = symbol.strip().upper()
bundle = {
"symbol": symbol,
"generated_at": datetime.now(timezone.utc).isoformat(),
"source": {
"library": "yfinance 1.7.0",
"upstream": "Yahoo Finance",
"scope": "Personal research and educational use",
},
"evidence": [],
"missing": [],
"conflicts": [],
"quality_notes": [
"Independent-source conflicts are not checked in this single-source project.",
"AI citations prove that an evidence ID exists, not that an interpretation is objectively correct.",
],
}
ticker = yf.Ticker(symbol)
try:
history = ticker.history(period="1y", auto_adjust=True)
close = history["Close"].dropna()
if close.empty:
bundle["missing"].append("Adjusted closing-price history returned no rows.")
else:
latest_close = float(close.iloc[-1])
latest_period = period_label(close.index[-1])
add_evidence(
bundle,
"Latest adjusted close",
latest_close,
f"{latest_close:,.2f} quote units",
latest_period,
"Ticker.history(period='1y', auto_adjust=True) -> Close",
)
if len(close) >= 2 and float(close.iloc[0]) != 0:
first_close = float(close.iloc[0])
return_percent = ((latest_close / first_close) - 1) * 100
add_evidence(
bundle,
"Available-period adjusted-close return",
return_percent,
f"{return_percent:,.2f}%",
f"{period_label(close.index[0])} to {latest_period}",
"Ticker.history(period='1y', auto_adjust=True) -> Close",
"((latest close / first close) - 1) * 100",
)
else:
bundle["missing"].append(
"Adjusted-close return needs at least two nonzero observations."
)
except Exception as error:
bundle["missing"].append(
f"Price history unavailable because {type(error).__name__} was raised."
)
income = load_statement(
bundle,
"Annual income statement",
lambda: ticker.get_income_stmt(freq="yearly"),
)
balance = load_statement(
bundle,
"Annual balance sheet",
lambda: ticker.get_balance_sheet(freq="yearly"),
)
cash_flow = load_statement(
bundle,
"Annual cash-flow statement",
lambda: ticker.get_cash_flow(freq="yearly"),
)
revenue_points = add_latest_statement_metric(
bundle,
income,
"TotalRevenue",
"Latest total revenue",
"get_income_stmt(freq='yearly') -> TotalRevenue",
)
net_income_points = add_latest_statement_metric(
bundle,
income,
"NetIncome",
"Latest net income",
"get_income_stmt(freq='yearly') -> NetIncome",
)
add_latest_statement_metric(
bundle,
balance,
"TotalDebt",
"Latest total debt",
"get_balance_sheet(freq='yearly') -> TotalDebt",
)
add_latest_statement_metric(
bundle,
cash_flow,
"OperatingCashFlow",
"Latest operating cash flow",
"get_cash_flow(freq='yearly') -> OperatingCashFlow",
)
add_latest_statement_metric(
bundle,
cash_flow,
"FreeCashFlow",
"Latest free cash flow",
"get_cash_flow(freq='yearly') -> FreeCashFlow",
)
if len(revenue_points) >= 2 and revenue_points[1][1] != 0:
latest_period, latest_revenue = revenue_points[0]
prior_period, prior_revenue = revenue_points[1]
revenue_change = ((latest_revenue / prior_revenue) - 1) * 100
add_evidence(
bundle,
"Annual total-revenue change",
revenue_change,
f"{revenue_change:,.2f}%",
f"{period_label(prior_period)} to {period_label(latest_period)}",
"Calculated from TotalRevenue",
"((latest revenue / prior revenue) - 1) * 100",
)
else:
bundle["missing"].append(
"Annual total-revenue change needs two usable annual values."
)
revenue_by_period = {
period_label(period): value for period, value in revenue_points
}
net_income_by_period = {
period_label(period): value for period, value in net_income_points
}
shared_periods = sorted(
set(revenue_by_period) & set(net_income_by_period), reverse=True
)
margin_added = False
for shared_period in shared_periods:
revenue = revenue_by_period[shared_period]
if revenue != 0:
margin = (net_income_by_period[shared_period] / revenue) * 100
add_evidence(
bundle,
"Net-income margin",
margin,
f"{margin:,.2f}%",
shared_period,
"Calculated from NetIncome and TotalRevenue",
"(net income / total revenue) * 100",
)
margin_added = True
break
if not margin_added:
bundle["missing"].append(
"Net-income margin needs revenue and net income for the same period."
)
destination = output_dir(symbol)
destination.mkdir(parents=True, exist_ok=True)
evidence_path = destination / "evidence.json"
evidence_path.write_text(
json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8"
)
return bundle
def main() -> None:
parser = argparse.ArgumentParser(description="Collect traceable stock evidence.")
parser.add_argument("ticker", nargs="?", default="MSFT")
args = parser.parse_args()
bundle = collect_evidence(args.ticker)
path = output_dir(bundle["symbol"]) / "evidence.json"
print(f"Saved {path} with {len(bundle['evidence'])} evidence records.")
if __name__ == "__main__":
main()
How to use this reference
Check function order plus indentation against your file. Keep your generated market values in evidence.json because they vary by retrieval time.
That is the evidence foundation complete. Next, you will expose what happens when plausible AI prose operates without this validation boundary.
Create the Unsafe Draft Demo
Your deterministic collector already turns live market data into traceable evidence records. This step exposes what happens when polished analysis ignores those records.
A plausible-looking draft can contain invented numbers or nonexistent citations. Seeing that failure makes the publishing risk concrete.
In this step, get ready to:
- Create a clearly labeled synthetic failure fixture.
- Generate exactly three plausible-looking interpretations.
- Run the fixture to create its non-publishable output.
Create the unsafe draft fixture
A synthetic failure fixture contains known-bad data for a controlled demonstration. Its warning label keeps it separate from company research.
- In the Visual Studio Code window from earlier, select the validated-stock-research folder in the Explorer sidebar.
- Click the New File icon in the Explorer sidebar.
- Enter unsafe_demo.py in the filename field.
- Press Enter to create the file.
- Build the synthetic fixture by adding this code to unsafe_demo.py:
import json
from pathlib import Path
draft = {
"interpretations": [
{
"statement": "Revenue increased by 999 percent and proves guaranteed growth.",
"evidence_ids": ["E999"],
"risk_level": "low",
},
{
"statement": "Debt has doubled without supporting evidence.",
"evidence_ids": ["E998"],
"risk_level": "high",
},
{
"statement": "The business is guaranteed to outperform the market.",
"evidence_ids": ["E997"],
"risk_level": "low",
},
]
}
path = Path("draft.json")
path.write_text(json.dumps(draft, indent=2), encoding="utf-8")
print(f"UNSAFE DEMO ONLY: wrote {path}")
What does this code do?
- The draft dictionary holds exactly three interpretation-shaped records.
- The statements deliberately sound confident.
- Their evidence IDs are synthetic.
- The first statement includes an invented numeric claim.
- The json.dumps() call formats the dictionary as readable JSON.
- The final line prints a warning that identifies the output as an unsafe demonstration.
- Save unsafe_demo.py by pressing Ctrl+S.
- Confirm unsafe_demo.py appears beside collect.py in the Explorer sidebar.
Don't see unsafe_demo.py?
- Confirm the validated-stock-research folder is selected in the Explorer sidebar.
- Check that the active editor tab is named unsafe_demo.py.
- Save the file again with Ctrl+S.
Help me find why unsafe_demo.py is missing from my Explorer.
✔️ Awesome, I've got everything!
Good progress: unsafe_demo.py is saved as a controlled synthetic fixture.
ⓧ I'd like to double check the full code
The complete unsafe_demo.py file should match this reference.
import json
from pathlib import Path
draft = {
"interpretations": [
{
"statement": "Revenue increased by 999 percent and proves guaranteed growth.",
"evidence_ids": ["E999"],
"risk_level": "low",
},
{
"statement": "Debt has doubled without supporting evidence.",
"evidence_ids": ["E998"],
"risk_level": "high",
},
{
"statement": "The business is guaranteed to outperform the market.",
"evidence_ids": ["E997"],
"risk_level": "low",
},
]
}
path = Path("draft.json")
path.write_text(json.dumps(draft, indent=2), encoding="utf-8")
print(f"UNSAFE DEMO ONLY: wrote {path}")
How does the full file fit together?
- The imports support JSON serialization plus local file writing.
- The draft dictionary defines all three known-bad interpretations.
- The final lines save the fixture to draft.json.
- The printed warning makes the fixture's unsafe status visible.
Run and inspect the generated draft
Orderly structure can make unsupported claims look credible. Running this fixture gives you a concrete artifact to assess.
Before you run this, do you think its tidy structure is enough to make the claims publishable?
- Generate the unsafe draft by running this command in the existing PowerShell terminal:
python unsafe_demo.py
What does this run prove?
The script writes draft.json beside unsafe_demo.py.
The terminal confirms the result with UNSAFE DEMO ONLY: wrote draft.json.
That result is useful: draft.json now captures the unsupported-claim problem in a repeatable file.
- Return to the Explorer sidebar in Visual Studio Code.
- Select draft.json.
- Count the entries under interpretations.
- Locate 999 in the first statement.
- Locate E999, E998, and E997 in the evidence_ids arrays.
Why is this draft non-publishable?
The file contains exactly three interpretations. The risk_level values appear in the order low, high, and low.
The first statement contains the unsupported number 999. The citations E999, E998, and E997 are unknown to the current evidence bundle.
Confident language plus citation-shaped strings provides no assurance on its own. This fixture must remain unpublished.
Don't see draft.json?
- Confirm the PowerShell terminal is inside the validated-stock-research folder.
- Confirm unsafe_demo.py contains the final path.write_text() call.
- Save unsafe_demo.py before rerunning the command above.
Help me diagnose why unsafe_demo.py did not create draft.json.
You have made the unsupported-claim problem visible. Next, you'll turn this fixture into a fail-closed validation test for every future report.
Add the Fail-Closed Report Gate
Your synthetic draft exposed the danger hiding inside plausible AI prose. Its polished structure still carried invented numbers plus citations that did not exist in the evidence bundle.
A fail-closed gate stops that draft before publication. Pydantic checks the response structure. Python applies the evidence rules that structure alone cannot enforce.
The same gate controls live output from Ollama. Local Mistral receives another attempt only when its first response fails validation.
In this step, get ready to:
- Define the structured analysis contract.
- Block unsupported statements plus unknown evidence citations.
- Publish a Markdown report only after every validation check passes.
Define the structured contract
A structured-output schema limits the shape of each model response. The custom validator then checks whether the content is safe enough to publish.
- Return to Visual Studio Code from the previous step.
- Click the New File button in the Explorer sidebar.
- Enter app.py as the file name.
You should see app.py beside collect.py plus unsafe_demo.py in the Explorer sidebar.
- Add the imports plus structured models to app.py by pasting this code:
import argparse
import json
import re
from pathlib import Path
from typing import Literal
from ollama import chat
from pydantic import BaseModel, ConfigDict, ValidationError
from collect import collect_evidence, output_dir
class Interpretation(BaseModel):
model_config = ConfigDict(extra="forbid")
statement: str
evidence_ids: list[str]
risk_level: Literal["low", "medium", "high"]
class Analysis(BaseModel):
model_config = ConfigDict(extra="forbid")
interpretations: list[Interpretation]
What does this code do?
- The imports connect command-line parsing with JSON handling plus regular-expression checks.
- The Interpretation model requires a statement plus evidence citations plus a controlled risk label.
- The Analysis model places every interpretation inside one validated response object.
- The extra="forbid" configuration rejects unexpected fields before custom validation begins.
- Save app.py by pressing Ctrl+S.
- Open the Outline panel in the Explorer sidebar to confirm that Interpretation plus Analysis appear.
Do the models show syntax errors?
Check that both class bodies remain indented by four spaces. Confirm that each opening bracket has a matching closing bracket.
Ask for help with the exact error shown in your editor: Help me fix the syntax error in my Pydantic Analysis and Interpretation models.
Number words can carry unsupported quantities without using digits. A dedicated set gives the validator a deterministic list to detect.
- Add the prohibited number-word set below the Analysis class by pasting this code:
NUMBER_WORDS = {
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"hundred",
"thousand",
"million",
"billion",
"trillion",
}
Why check number words?
A model can write a quantity as a word even when no digit appears. NUMBER_WORDS closes that route with a predictable vocabulary check.
- Save app.py.
- Confirm that NUMBER_WORDS appears in the Outline panel.
The first half of the validator checks the required interpretation count plus the prose itself. Each statement must be present and unique.
- Add the validation function below NUMBER_WORDS by pasting this code:
def validate_analysis(analysis: Analysis, bundle: dict) -> list[str]:
errors = []
valid_ids = {record["id"] for record in bundle["evidence"]}
if len(analysis.interpretations) != 3:
errors.append("The analysis must contain exactly three interpretations.")
seen_statements = set()
for index, interpretation in enumerate(analysis.interpretations, start=1):
statement = interpretation.statement.strip()
words = set(re.findall(r"[a-z]+", statement.lower()))
if not statement:
errors.append(f"Interpretation {index} has an empty statement.")
if statement in seen_statements:
errors.append(f"Interpretation {index} repeats another statement.")
seen_statements.add(statement)
if any(character.isdigit() for character in statement):
errors.append(f"Interpretation {index} contains a digit.")
if any(symbol in statement for symbol in "%$€£"):
errors.append(f"Interpretation {index} contains a quantitative symbol.")
if words & NUMBER_WORDS:
errors.append(f"Interpretation {index} contains a number word.")
return errors
How does prose validation work?
- The evidence IDs from the current bundle become the only valid citation set.
- The interpretation count must equal three.
- The loop rejects empty statements plus repeated statements.
- The digit plus symbol plus number-word checks keep quantitative claims out of AI prose.
- Save app.py.
- Confirm that validate_analysis appears in the Outline panel.
Is the validator missing from Outline?
Check that def validate_analysis starts at the far-left edge of the file. An accidental indent can place the function inside the number-word set or a class.
Use the editor error location when asking for help: Help me find the indentation problem around validate_analysis in app.py.
Citation checks connect each interpretation to the current evidence bundle. Unknown IDs cannot pass just because they match the expected text pattern.
- Find the number-word check inside validate_analysis.
- Insert the citation checks after that check by pasting this code before return errors:
if not interpretation.evidence_ids:
errors.append(f"Interpretation {index} has no evidence citation.")
if len(interpretation.evidence_ids) > 3:
errors.append(f"Interpretation {index} cites more than three evidence IDs.")
if len(set(interpretation.evidence_ids)) != len(
interpretation.evidence_ids
):
errors.append(f"Interpretation {index} repeats an evidence ID.")
unknown_ids = sorted(set(interpretation.evidence_ids) - valid_ids)
if unknown_ids:
errors.append(
f"Interpretation {index} cites unknown evidence IDs: "
+ ", ".join(unknown_ids)
)
How do citation checks fail closed?
- An empty citation list creates an error.
- More than three citations creates an error.
- Repeated evidence IDs create an error.
- Any ID outside the current bundle creates an error containing the unknown IDs.
- Save app.py.
- Confirm that the inserted lines remain inside the for loop.
Build the retry and publishing pipeline
The prompt gives Mistral a bounded evidence package plus the exact response schema. Validation feedback from a failed attempt becomes guidance for the final controlled retry.
- Add the prompt builder below validate_analysis by pasting this code:
def model_prompt(bundle: dict, feedback: list[str]) -> str:
evidence = [
{
"id": record["id"],
"metric": record["metric"],
"display_value": record["display_value"],
"period": record["period"],
"source_field": record["source_field"],
}
for record in bundle["evidence"]
]
schema_text = json.dumps(Analysis.model_json_schema(), indent=2)
feedback_text = "\n".join(feedback) if feedback else "None"
return f"""
Create exactly three cautious interpretations from the evidence package.
Rules:
- Each statement must cite between one and three existing evidence IDs.
- Statements must contain no digits, number words, currency signs, or percent signs.
- Describe directional meaning only. Do not repeat numeric values in prose.
- Do not promise performance or give buy, sell, or hold advice.
- A citation means the evidence was considered. It does not make the interpretation certain.
- Return only JSON matching the supplied schema.
Evidence package:
{json.dumps(evidence, indent=2)}
JSON schema:
{schema_text}
Previous validation feedback:
{feedback_text}
""".strip()
What goes into the prompt?
- The evidence package includes traceable IDs plus labels plus periods plus source fields.
- The JSON schema describes the only accepted response structure.
- The rules keep numeric values inside deterministic evidence.
- The feedback text tells a retry why the previous response failed.
- Save app.py.
- Confirm that model_prompt appears in the Outline panel.
Why deterministic evidence plus local AI?
Python owns arithmetic plus evidence IDs plus publication rules. Mistral supplies cautious directional interpretation from that bounded package.
This hybrid keeps local AI in the workflow without delegating numeric truth to free-form generation. A facts-only report would lose the interpretation layer.
A controlled generation loop allows two attempts at most. Every raw response remains available for debugging even when publication is blocked.
- Add the controlled generator below model_prompt by pasting this code:
def generate_analysis(bundle: dict, destination: Path) -> Analysis:
feedback = []
for attempt in range(1, 3):
response = chat(
model="mistral",
messages=[
{
"role": "system",
"content": "You are a cautious financial research assistant. Separate evidence from interpretation.",
},
{"role": "user", "content": model_prompt(bundle, feedback)},
],
format=Analysis.model_json_schema(),
options={"temperature": 0},
)
raw_content = response.message.content
(destination / f"raw_model_attempt_{attempt}.txt").write_text(
raw_content, encoding="utf-8"
)
try:
analysis = Analysis.model_validate_json(raw_content)
except ValidationError as error:
feedback = [f"Schema validation failed: {error}"]
continue
feedback = validate_analysis(analysis, bundle)
if not feedback:
return analysis
raise RuntimeError(
"Model output failed validation after two attempts: " + "; ".join(feedback)
)
How are retries controlled?
- The range permits no more than two model attempts.
- The response must match the Analysis schema before custom validation runs.
- Each raw response is written to a numbered debugging file.
- The function returns only after every validation error disappears.
- Save app.py.
- Confirm that generate_analysis appears in the Outline panel.
Does the generator show an indentation error?
Keep the parsing block plus feedback check inside the attempt loop. Keep the final raise RuntimeError outside that loop.
Share the editor location without sharing any credentials: Help me correct the indentation in generate_analysis without changing its two-attempt limit.
The report starts with deterministic evidence. A final return joins every report line into one Markdown document.
- Add the Markdown helper plus report foundation below generate_analysis by pasting this code:
def markdown_cell(value: object) -> str:
return str(value).replace("|", "\\|").replace("\n", " ")
def render_report(bundle: dict, analysis: Analysis) -> str:
lines = [
f"# Validated Stock Research: {bundle['symbol']}",
"",
f"Generated at: {bundle['generated_at']}",
"",
"This is a research demonstration, not investment advice.",
"",
"## Deterministic Evidence",
"",
"| ID | Metric | Value | Period | Source field |",
"| --- | --- | --- | --- | --- |",
]
return "\n".join(lines)
How does report rendering begin?
The cell helper escapes table separators plus line breaks. The initial list creates the report title plus timestamp plus deterministic-evidence table headings.
- Save app.py.
- Confirm that markdown_cell plus render_report appear in the Outline panel.
Each evidence record becomes one Markdown table row. The renderer uses the exact values already calculated by the collector.
- Find return "\n".join(lines) inside render_report.
- Insert the evidence rows above that return by pasting this code:
for record in bundle["evidence"]:
lines.append(
"| "
+ " | ".join(
markdown_cell(record[key])
for key in (
"id",
"metric",
"display_value",
"period",
"source_field",
)
)
+ " |"
)
How is the evidence table built?
The loop reads each evidence field in a fixed order. markdown_cell protects the Markdown table from separators inside source text.
- Save app.py.
- Confirm that the evidence loop remains above the final return in render_report.
Validated interpretations are visibly separated from deterministic metrics. The next section also discloses every missing field from the evidence bundle.
- Insert the interpretation plus missing-data sections below the evidence loop by pasting this code:
lines.extend(["", "## AI Interpretations", ""])
for interpretation in analysis.interpretations:
citations = ", ".join(interpretation.evidence_ids)
lines.append(
f"- {interpretation.risk_level.title()} risk: "
f"{interpretation.statement.strip()} [{citations}]"
)
lines.extend(["", "## Data Quality", "", "### Missing data", ""])
if bundle["missing"]:
lines.extend(f"- {item}" for item in bundle["missing"])
else:
lines.append("- No requested field was missing in this run.")
What becomes visible here?
Each interpretation shows its risk label plus evidence citations. Missing fields remain visible instead of turning into fabricated zero values.
- Save app.py.
- Confirm that the new lines remain inside render_report.
The assurance boundary states exactly what the software can prove. Citation existence still requires human review for semantic support.
- Insert the conflict scope plus assurance boundary above the final return by pasting this code:
lines.extend(["", "### Conflict scope", ""])
lines.append(
"- Independent-source conflicts were not checked because this project uses one yfinance data pipeline."
)
for note in bundle["quality_notes"]:
lines.append(f"- {note}")
lines.extend(
[
"",
"## Assurance Boundary",
"",
"The program verifies structure, citation existence, and the absence of numeric AI prose. It does not prove that the upstream data is correct or that a cited interpretation is semantically entailed. Human review is required before relying on the analysis.",
"",
]
)
What does the assurance boundary disclose?
The report names its single-source limitation. It also separates programmed checks from judgments that still belong to a human reviewer.
- Save app.py.
- Confirm that return "\n".join(lines) remains the final line inside render_report.
The draft-checking path parses an existing JSON file before running the custom rules. Any parsing error or validation error returns a failing status.
- Add the draft checker below render_report by pasting this code:
def check_draft(path: Path, bundle: dict) -> int:
try:
analysis = Analysis.model_validate_json(path.read_text(encoding="utf-8"))
except (OSError, ValidationError) as error:
print("VALIDATION FAILED")
print(error)
return 1
errors = validate_analysis(analysis, bundle)
if errors:
print("VALIDATION FAILED")
for error in errors:
print(f"- {error}")
return 1
print("VALIDATION PASSED")
return 0
How does draft checking stop publication?
Schema failures stop before custom validation. Custom failures print every detected problem plus a failing return code.
- Save app.py.
- Confirm that check_draft appears in the Outline panel.
The command-line entry point always refreshes the evidence bundle for the selected ticker. The optional draft path routes directly through the same validator.
- Add the command-line foundation below check_draft by pasting this code:
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a fail-closed local stock research report."
)
parser.add_argument("ticker", nargs="?", default="MSFT")
parser.add_argument("--check-draft", type=Path)
args = parser.parse_args()
bundle = collect_evidence(args.ticker)
destination = output_dir(bundle["symbol"])
if args.check_draft:
return check_draft(args.check_draft, bundle)
How does the command choose a path?
The ticker defaults to MSFT when no symbol is supplied. The draft option switches from live generation to validation of an existing file.
- Save app.py.
- Confirm that main appears in the Outline panel.
Live generation needs enough evidence to support three interpretations. A blocked run writes a failure log instead of creating a report.
- Continue main below the draft-check branch by pasting this indented code:
if len(bundle["evidence"]) < 3:
message = "REPORT BLOCKED: fewer than three evidence records are available."
(destination / "validation_failure.txt").write_text(message, encoding="utf-8")
print(message)
return 1
try:
analysis = generate_analysis(bundle, destination)
except Exception as error:
message = f"REPORT BLOCKED: {type(error).__name__}: {error}"
(destination / "validation_failure.txt").write_text(message, encoding="utf-8")
print(message)
return 1
Where does the application fail closed?
Insufficient evidence blocks generation immediately. Schema failures plus custom validation failures become validation_failure.txt after the controlled attempts finish.
- Save app.py.
- Confirm that both blocking branches remain inside main.
The publication path is deliberately short. It becomes reachable only after generate_analysis returns a validated object.
- Finish app.py by adding the report writer plus entry-point guard:
report_path = destination / "report.md"
report_path.write_text(render_report(bundle, analysis), encoding="utf-8")
print(f"REPORT READY: {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Why is report creation last?
The report path is created only after schema parsing plus every custom check succeeds. The entry-point guard returns the command status to the terminal.
- Save app.py.
- Confirm that the editor shows no syntax problems in app.py.
Still seeing a Python error?
Check that the report-writing lines remain indented inside main. Keep the final entry-point guard at the far-left edge.
Ask for help with the exact editor message: Help me fix the final indentation or syntax error in app.py without changing its validation rules.
Challenge the gate and publish safely
The synthetic fixture now faces the same gate as live model output. Its valid outer shape should not protect its unsupported content.
Before you run the check, predict whether the three interpretations reach publication.
- Test the unsafe fixture from the Visual Studio Code PowerShell terminal by running:
python app.py MSFT --check-draft draft.json
What does this command test?
The application refreshes the current MSFT evidence bundle. It then compares every statement plus citation in draft.json with the report-gate rules.
You should see VALIDATION FAILED followed by errors for numeric prose plus unknown evidence IDs. That failure proves the unsafe fixture cannot pass the gate.
Did the draft unexpectedly pass?
Confirm that draft.json still contains 999 plus the unknown citations from the synthetic fixture. Check that the citation block sits inside the validator loop.
Use the result when asking for help: Why did my unsafe draft pass validate_analysis?
That is the intended failure: plausible formatting no longer earns publication. The live path must satisfy the same schema plus content rules.
Before you start local generation, predict which file appears only after every check passes.
- Generate the validated local report by running:
python app.py MSFT
What happens during this run?
The collector refreshes deterministic evidence before Mistral receives the bounded prompt. Each attempted response is saved before schema plus custom validation run.
A valid analysis produces REPORT READY plus the path to outputs/MSFT/report.md.
- Expand outputs in the Visual Studio Code Explorer sidebar.
- Expand MSFT to confirm that report.md exists.
- Select report.md to review the generated report.
- Confirm that the report contains deterministic evidence plus three AI interpretations plus data-quality disclosures plus an assurance boundary.
You made the publication boundary real. The assistant now writes a report only when local model output survives every programmed check.
Was the report blocked?
Open outputs/MSFT/validation_failure.txt to see why publication stopped. Compare it with the latest raw attempt file in the same folder.
The blocked result is safe behavior. Ask for help diagnosing the cause without weakening validation: Help me understand why my local Mistral output failed the report gate.
✔️ Awesome, I've got everything!
Your unsafe fixture is blocked. Your validated local run creates outputs/MSFT/report.md only after every gate passes.
ⓧ I'd like to double check the full code
Compare your saved app.py with this complete reference:
import argparse
import json
import re
from pathlib import Path
from typing import Literal
from ollama import chat
from pydantic import BaseModel, ConfigDict, ValidationError
from collect import collect_evidence, output_dir
class Interpretation(BaseModel):
model_config = ConfigDict(extra="forbid")
statement: str
evidence_ids: list[str]
risk_level: Literal["low", "medium", "high"]
class Analysis(BaseModel):
model_config = ConfigDict(extra="forbid")
interpretations: list[Interpretation]
NUMBER_WORDS = {
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"hundred",
"thousand",
"million",
"billion",
"trillion",
}
def validate_analysis(analysis: Analysis, bundle: dict) -> list[str]:
errors = []
valid_ids = {record["id"] for record in bundle["evidence"]}
if len(analysis.interpretations) != 3:
errors.append("The analysis must contain exactly three interpretations.")
seen_statements = set()
for index, interpretation in enumerate(analysis.interpretations, start=1):
statement = interpretation.statement.strip()
words = set(re.findall(r"[a-z]+", statement.lower()))
if not statement:
errors.append(f"Interpretation {index} has an empty statement.")
if statement in seen_statements:
errors.append(f"Interpretation {index} repeats another statement.")
seen_statements.add(statement)
if any(character.isdigit() for character in statement):
errors.append(f"Interpretation {index} contains a digit.")
if any(symbol in statement for symbol in "%$€£"):
errors.append(f"Interpretation {index} contains a quantitative symbol.")
if words & NUMBER_WORDS:
errors.append(f"Interpretation {index} contains a number word.")
if not interpretation.evidence_ids:
errors.append(f"Interpretation {index} has no evidence citation.")
if len(interpretation.evidence_ids) > 3:
errors.append(f"Interpretation {index} cites more than three evidence IDs.")
if len(set(interpretation.evidence_ids)) != len(
interpretation.evidence_ids
):
errors.append(f"Interpretation {index} repeats an evidence ID.")
unknown_ids = sorted(set(interpretation.evidence_ids) - valid_ids)
if unknown_ids:
errors.append(
f"Interpretation {index} cites unknown evidence IDs: "
+ ", ".join(unknown_ids)
)
return errors
def model_prompt(bundle: dict, feedback: list[str]) -> str:
evidence = [
{
"id": record["id"],
"metric": record["metric"],
"display_value": record["display_value"],
"period": record["period"],
"source_field": record["source_field"],
}
for record in bundle["evidence"]
]
schema_text = json.dumps(Analysis.model_json_schema(), indent=2)
feedback_text = "\n".join(feedback) if feedback else "None"
return f"""
Create exactly three cautious interpretations from the evidence package.
Rules:
- Each statement must cite between one and three existing evidence IDs.
- Statements must contain no digits, number words, currency signs, or percent signs.
- Describe directional meaning only. Do not repeat numeric values in prose.
- Do not promise performance or give buy, sell, or hold advice.
- A citation means the evidence was considered. It does not make the interpretation certain.
- Return only JSON matching the supplied schema.
Evidence package:
{json.dumps(evidence, indent=2)}
JSON schema:
{schema_text}
Previous validation feedback:
{feedback_text}
""".strip()
def generate_analysis(bundle: dict, destination: Path) -> Analysis:
feedback = []
for attempt in range(1, 3):
response = chat(
model="mistral",
messages=[
{
"role": "system",
"content": "You are a cautious financial research assistant. Separate evidence from interpretation.",
},
{"role": "user", "content": model_prompt(bundle, feedback)},
],
format=Analysis.model_json_schema(),
options={"temperature": 0},
)
raw_content = response.message.content
(destination / f"raw_model_attempt_{attempt}.txt").write_text(
raw_content, encoding="utf-8"
)
try:
analysis = Analysis.model_validate_json(raw_content)
except ValidationError as error:
feedback = [f"Schema validation failed: {error}"]
continue
feedback = validate_analysis(analysis, bundle)
if not feedback:
return analysis
raise RuntimeError(
"Model output failed validation after two attempts: " + "; ".join(feedback)
)
def markdown_cell(value: object) -> str:
return str(value).replace("|", "\\|").replace("\n", " ")
def render_report(bundle: dict, analysis: Analysis) -> str:
lines = [
f"# Validated Stock Research: {bundle['symbol']}",
"",
f"Generated at: {bundle['generated_at']}",
"",
"This is a research demonstration, not investment advice.",
"",
"## Deterministic Evidence",
"",
"| ID | Metric | Value | Period | Source field |",
"| --- | --- | --- | --- | --- |",
]
for record in bundle["evidence"]:
lines.append(
"| "
+ " | ".join(
markdown_cell(record[key])
for key in (
"id",
"metric",
"display_value",
"period",
"source_field",
)
)
+ " |"
)
lines.extend(["", "## AI Interpretations", ""])
for interpretation in analysis.interpretations:
citations = ", ".join(interpretation.evidence_ids)
lines.append(
f"- {interpretation.risk_level.title()} risk: "
f"{interpretation.statement.strip()} [{citations}]"
)
lines.extend(["", "## Data Quality", "", "### Missing data", ""])
if bundle["missing"]:
lines.extend(f"- {item}" for item in bundle["missing"])
else:
lines.append("- No requested field was missing in this run.")
lines.extend(["", "### Conflict scope", ""])
lines.append(
"- Independent-source conflicts were not checked because this project uses one yfinance data pipeline."
)
for note in bundle["quality_notes"]:
lines.append(f"- {note}")
lines.extend(
[
"",
"## Assurance Boundary",
"",
"The program verifies structure, citation existence, and the absence of numeric AI prose. It does not prove that the upstream data is correct or that a cited interpretation is semantically entailed. Human review is required before relying on the analysis.",
"",
]
)
return "\n".join(lines)
def check_draft(path: Path, bundle: dict) -> int:
try:
analysis = Analysis.model_validate_json(path.read_text(encoding="utf-8"))
except (OSError, ValidationError) as error:
print("VALIDATION FAILED")
print(error)
return 1
errors = validate_analysis(analysis, bundle)
if errors:
print("VALIDATION FAILED")
for error in errors:
print(f"- {error}")
return 1
print("VALIDATION PASSED")
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate a fail-closed local stock research report."
)
parser.add_argument("ticker", nargs="?", default="MSFT")
parser.add_argument("--check-draft", type=Path)
args = parser.parse_args()
bundle = collect_evidence(args.ticker)
destination = output_dir(bundle["symbol"])
if args.check_draft:
return check_draft(args.check_draft, bundle)
if len(bundle["evidence"]) < 3:
message = "REPORT BLOCKED: fewer than three evidence records are available."
(destination / "validation_failure.txt").write_text(message, encoding="utf-8")
print(message)
return 1
try:
analysis = generate_analysis(bundle, destination)
except Exception as error:
message = f"REPORT BLOCKED: {type(error).__name__}: {error}"
(destination / "validation_failure.txt").write_text(message, encoding="utf-8")
print(message)
return 1
report_path = destination / "report.md"
report_path.write_text(render_report(bundle, analysis), encoding="utf-8")
print(f"REPORT READY: {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
This reference combines the structured models plus validator plus retry loop plus report renderer plus command-line entry point.
Your local assistant now separates raw attempts from publishable output. Next up, you will document the assurance boundary and package the project for review.
Package the Project for Review
Your assistant now blocks the unsafe fixture and publishes a report only after every programmed check passes. The validation path is working.
A trustworthy AI project also needs to show another person how to reproduce the result. Its documentation must expose the assurance boundary instead of hiding limitations behind the finished report.
In this step, get ready to:
- Document the setup process and assurance contract.
- Exclude local artifacts from the reproducible snapshot.
- Commit a clean Git snapshot on the main branch.
Document the assurance contract
The README.md file is a Markdown guide for reviewers. It explains what the application guarantees and where human judgment still matters.
- In the file sidebar in Visual Studio Code from earlier, create README.md inside the validated-stock-research folder.
- Add the project overview and assurance contract by pasting this content into README.md:
# Validated Stock Research Assistant
This Windows-friendly Python project collects stock evidence with yfinance, asks a local Mistral model for cautious interpretations, and publishes Markdown only after validation succeeds.
## Assurance contract
The application checks that:
- Model output matches the required Pydantic structure.
- Exactly three interpretations are returned.
- Every citation refers to an evidence ID in the current evidence bundle.
- AI interpretation text contains no digits, common number words, currency signs, or percent signs.
- Missing data is disclosed instead of replaced with zero.
- A report is not written after failed validation.
The application does not prove that Yahoo Finance data is correct or that cited evidence semantically guarantees an interpretation. It uses a single yfinance pipeline, so it cannot detect disagreement between independent sources. Human review remains required.
What Does This Section Explain?
- The assurance contract shows how Pydantic structure and custom validation control publication.
- The final paragraph separates citation existence from semantic support.
- The human-review statement keeps the software boundary visible to every reviewer.
- Save README.md.
- Confirm the file shows the project heading and six assurance checks in the editor.
Missing Part of the Assurance Contract?
Compare the six bullets with the block above. Check that the single-source limitation remains below the list.
Ask for help comparing the section without changing its claims: Help me find what is missing from the assurance contract in my README.md.
Reproduction depends on exact setup commands. The next section records how a reviewer recreates the Windows environment and restores the local model.
- Create one blank line after the assurance-boundary paragraph.
- Add the Windows setup section by pasting this content at the bottom of README.md:
## Windows setup
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
py -m pip install -r requirements.txt
ollama ls
```
If `mistral` is missing, restore it with:
```powershell
ollama pull mistral
```
What Does This Setup Record?
- The virtual-environment commands give the project an isolated Python runtime.
- The requirements command restores the pinned package versions from requirements.txt.
- The Ollama commands verify or restore the local Mistral model.
- Save README.md.
- Confirm the rendered text now includes the Windows setup heading and two PowerShell code sections.
Setup Commands Rendering as Plain Text?
Check that each PowerShell section starts and ends with three backticks. Keep the language label directly beside the opening backticks.
Ask for help checking the Markdown fences: Why are the PowerShell commands in my README.md not rendering as code blocks?
A reviewer also needs the exact learning sequence and data-use boundary. These sections connect the unsafe demonstration to the validated local report.
- Create one blank line after the Mistral restoration code section.
- Add the run sequence and usage boundaries by pasting this content at the bottom of README.md:
## Run the learning sequence
```powershell
python collect.py MSFT
python unsafe_demo.py
python app.py MSFT --check-draft draft.json
python app.py MSFT
```
The unsafe draft must fail validation. A successful live run writes `outputs/MSFT/evidence.json`, raw model attempts, and `outputs/MSFT/report.md`.
## Data-use note
yfinance describes itself as an open-source tool for research and educational purposes and states that the Yahoo Finance API is intended for personal use only. Review the applicable terms before using downloaded data beyond this learning project.
## Not investment advice
This project demonstrates software validation patterns. It does not provide investment advice, recommendations, or guarantees.
What Does This Review Path Prove?
- The learning sequence reproduces evidence collection before demonstrating the unsafe fixture.
- The draft check proves that unsupported output remains blocked before local report generation.
- The yfinance note records the personal-use warning for downloaded data.
- The final section prevents the research demonstration from being presented as investment advice.
- Save README.md.
- Confirm the file ends with the non-investment-advice boundary.
README Sections Out of Order?
Keep the assurance contract first. Place Windows setup before the learning sequence.
Ask for help comparing the complete file: Help me compare my README.md section order with the required project documentation.
✔️ Awesome, I've got everything!
Your README.md now documents reproduction, validation guarantees, data limits, and the human-review boundary.
ⓧ I'd like to double check the full code
Compare your complete README.md with this reference file.
# Validated Stock Research Assistant
This Windows-friendly Python project collects stock evidence with yfinance, asks a local Mistral model for cautious interpretations, and publishes Markdown only after validation succeeds.
## Assurance contract
The application checks that:
- Model output matches the required Pydantic structure.
- Exactly three interpretations are returned.
- Every citation refers to an evidence ID in the current evidence bundle.
- AI interpretation text contains no digits, common number words, currency signs, or percent signs.
- Missing data is disclosed instead of replaced with zero.
- A report is not written after failed validation.
The application does not prove that Yahoo Finance data is correct or that cited evidence semantically guarantees an interpretation. It uses a single yfinance pipeline, so it cannot detect disagreement between independent sources. Human review remains required.
## Windows setup
```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
py -m pip install -r requirements.txt
ollama ls
```
If `mistral` is missing, restore it with:
```powershell
ollama pull mistral
```
## Run the learning sequence
```powershell
python collect.py MSFT
python unsafe_demo.py
python app.py MSFT --check-draft draft.json
python app.py MSFT
```
The unsafe draft must fail validation. A successful live run writes `outputs/MSFT/evidence.json`, raw model attempts, and `outputs/MSFT/report.md`.
## Data-use note
yfinance describes itself as an open-source tool for research and educational purposes and states that the Yahoo Finance API is intended for personal use only. Review the applicable terms before using downloaded data beyond this learning project.
## Not investment advice
This project demonstrates software validation patterns. It does not provide investment advice, recommendations, or guarantees.
How to Use This Reference
Compare the headings, paragraphs, bullets, and commands in order. Keep every assurance limitation intact.
Exclude local artifacts and review the report
Your source snapshot should contain the code needed to reproduce a run. Disposable environments and generated research outputs stay local through .gitignore.
- In the Visual Studio Code file sidebar, create .gitignore inside the validated-stock-research folder.
- Add the local-only paths by pasting this content into .gitignore:
.venv/
__pycache__/
outputs/
draft.json
What Does This File Exclude?
- The .venv/ entry excludes the disposable Python environment.
- The __pycache__/ entry excludes generated Python cache files.
- The outputs/ entry keeps evidence, model attempts, failure logs, and reports local.
- The draft.json entry excludes the generated unsafe fixture.
- Save .gitignore.
- Confirm the file contains exactly four exclusion entries.
Generated Files Still Eligible for Tracking?
Check each entry for the exact slash placement shown above. Confirm the file is named .gitignore without another extension.
Ask for help checking the patterns: Why is my .gitignore not excluding the virtual environment or generated outputs?
✔️ Awesome, I've got everything!
Your .gitignore keeps disposable and generated artifacts outside the project snapshot.
ⓧ I'd like to double check the full code
Compare your complete .gitignore with this reference file.
.venv/
__pycache__/
outputs/
draft.json
How to Use This Reference
Confirm every path occupies its own line. Preserve the trailing slashes on the three directories.
The final local run proves the ignored output still works as review evidence. The report remains available on your computer without becoming part of the repository.
Before you run the application, do you expect the safety gate to publish a report or block the current model response?
- Generate the current validated report by running this command in the PowerShell terminal from earlier:
python app.py MSFT
What Does This Command Do?
The application refreshes the deterministic evidence and requests structured local interpretations. It writes outputs/MSFT/report.md only when schema and custom validation both pass.
You should see REPORT READY with the outputs/MSFT/report.md path. That message proves the current report passed the publication gate.
Report Blocked During the Review Run?
- Return to outputs/MSFT/validation_failure.txt to identify the failed schema or custom check.
- Confirm the local Mistral model from setup is still available.
- Run the application again after resolving the reported validation failure.
Ask for help interpreting the block without weakening validation: Why did my fail-closed report run get blocked?
- Return to outputs/MSFT/report.md in Visual Studio Code.
- Confirm the Deterministic Evidence section connects every metric to an evidence ID and reporting period.
- Confirm the AI Interpretations section contains three validated interpretations with evidence citations.
- Check the Missing data section for unavailable fields from the current run.
- Read the Conflict scope section to verify that independent-source conflicts were not checked.
- Read the Assurance Boundary section to identify the guarantees that remain outside the program.
You now have the whole trust story in one report. A reviewer can trace the evidence, inspect the interpretations, and see the limits of the programmed checks.
Commit the reproducible snapshot
The repository records the source files and documentation that reproduce the project. Ignored outputs remain available locally while the commit stays focused on reviewable code.
- In the PowerShell terminal from earlier, initialize the main branch by running this command:
git init -b main
What Does This Command Do?
The command initializes the current validated-stock-research folder as a repository. The main branch becomes the initial branch.
Git confirms that it initialized a repository in the current project folder.
Repository Initialized in the Wrong Folder?
Check that the PowerShell prompt points to the validated-stock-research folder. The project files should be visible in the same Visual Studio Code window.
Ask for help checking the repository location: Help me verify that I initialized Git inside validated-stock-research.
- Stage the reviewable project files by running this command:
git add .
What Does This Command Do?
The command stages project files that are not excluded by .gitignore. This prepares the reproducible source snapshot for its first commit.
- Inspect the staged snapshot by running this command:
git status
What Does This Check Show?
The status summary separates staged project files from ignored local artifacts. This check catches accidental output tracking before the commit.
You should see the source files, README.md, .gitignore, and requirements.txt staged. You should not see .venv/, outputs/, or draft.json in the snapshot.
- Create the initial project snapshot by running this command:
git commit -m "Build validated stock research assistant"
What Does This Command Do?
The command records every staged source file under the message Build validated stock research assistant. The snapshot can now be reviewed as one reproducible project state.
Git confirms the new commit on the main branch. Your project source is now recorded.
Commit Needs Your Identity?
Git may ask you to configure an author name or email before it can create the commit. Follow the guidance shown in your terminal before retrying the same commit command.
Ask for help with the local author setup: Help me configure the Git identity needed for this commit.
Before you run the final check, do you expect any tracked change to remain?
- Verify the committed project state by running this command:
git status
What Does This Final Check Prove?
A clean working tree means every tracked source change belongs to the initial commit. Ignored local outputs can remain on disk without making the repository dirty.
Git reports a clean working tree on the main branch. The current outputs/MSFT/report.md remains available locally for review.
That completes the review package. Another person can reproduce the workflow while seeing exactly what the validator guarantees.
Secret mission
Prove the Validator with Automated Tests
Future edits can quietly weaken a safety gate that works today. Build three offline regression tests that prove valid analysis passes while unknown citations and numeric prose remain blocked.
Clean Up Your Resources
Clean Up Your Resources
This local Windows project has no ongoing service charges. Decide whether to keep your resources available, pause the loaded model, or delete everything.
Resources you used:
- Your validated-stock-research folder. It contains the source files, regression tests, documentation, and Git history.
- Your .venv/, draft.json, and outputs/ resources inside validated-stock-research.
- Your local Mistral model stored by Ollama.
Keep everything running
No action needed. Choose this if you are still reviewing the report or extending the validator.
- Leave the validated-stock-research folder available for future development.
- Retain the Mistral model for future local inference.
Pause - I'll come back to this later
Shut down the loaded model to release its active resources while keeping every project file on disk.
- Stop the loaded Mistral model from PowerShell by running this command:
ollama stop mistral
This stops the loaded model process. The Mistral model remains available on disk for your next session.
- Close the PowerShell terminal after the command finishes.
Delete - I don't want to use this again
Deleting the project folder removes the source files, regression tests, virtual environment, local review outputs, and Git history. Removing Mistral also makes the model unavailable to other local projects.
- Close the validated-stock-research workspace in Visual Studio Code if it is still open.
- Delete the validated-stock-research folder in File Explorer.
The folder disappears from its previous location. This removes .venv/, draft.json, outputs/, and the Git repository with it.
- Remove Mistral only if no other local project needs it by running this command:
ollama rm mistral
This removes the local Mistral model files from Ollama. You need to retrieve the model again before another local project can use it.
Nice Work!
Nice Work!
You did it. You built a local stock research assistant that publishes a report only after every programmed check passes.
You've learned how to:
- Use deterministic calculations to create traceable records from yfinance data. Preserve each metric's reporting period. Record missing values as disclosures instead of zeroes.
- Expose unsupported numeric prose with the unsafe draft. Enforce a Pydantic schema before custom checks run. Apply fail-closed validation to local Mistral structured output. Reject unknown evidence IDs before report.md is written.
- Package the complete workflow in a reproducible Git repository. Document the missing-data disclosure. State the single-source limitation. Keep human semantic review visible in the assurance boundary.
- Secret Mission: Add offline regression tests for validate_analysis(). Prove the valid fixture passes. Confirm the E999 mutation fails. Confirm the numeric-prose mutation fails.
Ready to quiz yourself?