Miranda's Project Guide

Project: PlainText Pal Category: Web Development (Flask) + Anthropic Last updated: April 23

Note: This guide reflects the latest state of your project repo. It may not match the most up-to-date version if you've worked since.

In class today we lined up five polish items for Checkpoint 3: load the API key from .env, add a loading state on Analyze, show a "Suggested rewrite" textarea, have the LLM explain each readability score, and (bonus) let the user pick a target audience.

Phase 1: Load the API key from .env

Objective

Stop depending on a shell env var. Keep ANTHROPIC_API_KEY in a gitignored .env file that Flask loads at startup.

Instructions

Hints

.env.example (committed):

ANTHROPIC_API_KEY=

Top of app.py:

from dotenv import load_dotenv
load_dotenv()

from flask import Flask, render_template, request, flash, redirect, url_for
from pal import get_suggestions
from stats import get_readability_stats

Order matters — load_dotenv() must run before pal is imported, because pal.py reads os.getenv("ANTHROPIC_API_KEY") at call time and the Anthropic SDK is configured from that.

Phase 2: Disable the Analyze button + show a loading state

Objective

When someone clicks Analyze, the button should go disabled and show a spinner so they know the LLM is working (it takes a few seconds).

Instructions

Hints

Updated button:

<button id="analyze-btn" class="btn btn-primary" type="submit">
  <span id="analyze-label">Analyze Text</span>
  <span id="analyze-spinner"
        class="spinner-border spinner-border-sm ms-2 d-none"
        role="status" aria-hidden="true"></span>
</button>

Give the form an id:

<form id="analyze-form" method="post" action="{{ url_for('analyze') }}">

Script at the bottom of the {% block content %}:

<script>
  document.getElementById("analyze-form").addEventListener("submit", () => {
    document.getElementById("analyze-btn").disabled = true;
    document.getElementById("analyze-label").textContent = "Analyzing...";
    document.getElementById("analyze-spinner").classList.remove("d-none");
  });
</script>

No re-enable logic needed — Flask renders results.html on success, which replaces the whole page.

Phase 3: "Suggested rewrite" textarea

Objective

Below the suggestions, show a read-only textarea containing the same text rewritten with those suggestions applied. Same LLM call, just an extra JSON field.

Instructions

Hints

pal.py — update prompts:

system_prompt = (
    "You classify writing intent and provide plain-language accessibility suggestions. "
    "Return JSON only with keys: intent (string), suggestions (array of strings), "
    "rewrite (string). "
    "Intent examples include: scientific, creative, informative, persuasive, instructional, narrative."
)
user_prompt = (
    "Analyze the text below and return only valid JSON.\n\n"
    "Requirements:\n"
    "1) intent: one short label\n"
    "2) suggestions: 3-6 concise, specific ways to improve readability and accessibility\n"
    "3) rewrite: the original text rewritten applying your suggestions, in plain language\n\n"
    f"Text:\n{cleaned_text}"
)

pal.py — read the new field before the return:

rewrite = str(parsed.get("rewrite", "")).strip()

return {
    "intent": intent,
    "suggestions": cleaned_suggestions,
    "rewrite": rewrite,
}

Also add "rewrite": "" to _fallback_response so the template never crashes.

templates/results.html — new section under the suggestions list:

<h2 class="h5 mt-4">Suggested rewrite</h2>
<textarea class="form-control" rows="8" readonly>{{ pal_results.rewrite }}</textarea>

Phase 4: LLM-written score explanations

Objective

Under each readability stat, show a one-sentence plain-language explanation written by the LLM that references the actual number (e.g. "Your Flesch score of 62 is about 8th-grade reading — most readers will follow it fine.").

Instructions

Hints

app.py — reorder the two calls:

stats = get_readability_stats(text)
pal_results = get_suggestions(text, stats)

pal.py — new signature + prompt addition:

def get_suggestions(text: str, stats: dict | None = None) -> dict[str, Any]:
    ...
    stats_line = ""
    if stats:
        stats_line = (
            "\n\nReadability stats for this text:\n"
            f"- Flesch Reading Ease: {stats['flesch_ease']}\n"
            f"- Average sentence length: {stats['avg_sentence_length']} words\n"
            f"- Long sentences (>25 words): {stats['long_sentences']}\n"
            f"- Complex words: {stats['complex_words']}\n"
        )

    system_prompt = (
        "... (same as before) "
        "Also return score_explanations: an object with keys flesch_ease, "
        "avg_sentence_length, long_sentences, complex_words. Each value is "
        "a single plain-language sentence explaining what this specific "
        "number means for the reader."
    )
    user_prompt = (
        "Analyze the text below and return only valid JSON.\n\n"
        "Requirements:\n"
        "1) intent: one short label\n"
        "2) suggestions: 3-6 concise, specific ways to improve readability and accessibility\n"
        "3) rewrite: the original text rewritten applying your suggestions, in plain language\n"
        "4) score_explanations: one-sentence explanation per score"
        f"{stats_line}\n"
        f"Text:\n{cleaned_text}"
    )

pal.py — read the new field before the return:

raw_explanations = parsed.get("score_explanations", {}) or {}
score_explanations = {
    key: str(raw_explanations.get(key, "")).strip()
    for key in ("flesch_ease", "avg_sentence_length", "long_sentences", "complex_words")
}

return {
    "intent": intent,
    "suggestions": cleaned_suggestions,
    "rewrite": rewrite,
    "score_explanations": score_explanations,
}

Add a matching empty score_explanations dict to _fallback_response.

templates/results.html — wrap each stat with its explanation:

<ul>
  <li>
    <strong>Flesch Reading Ease:</strong> {{ stats.flesch_ease }}
    <div class="text-muted small">{{ pal_results.score_explanations.flesch_ease }}</div>
  </li>
  <li>
    <strong>Average sentence length:</strong> {{ stats.avg_sentence_length }}
    <div class="text-muted small">{{ pal_results.score_explanations.avg_sentence_length }}</div>
  </li>
  <li>
    <strong>Long sentences:</strong> {{ stats.long_sentences }}
    <div class="text-muted small">{{ pal_results.score_explanations.long_sentences }}</div>
  </li>
  <li>
    <strong>Complex words:</strong> {{ stats.complex_words }}
    <div class="text-muted small">{{ pal_results.score_explanations.complex_words }}</div>
  </li>
</ul>

Phase 5 (Bonus): Audience selector

Objective

Let the user pick a target audience — General, Accessibility (screen readers), ADHD, Dyslexia, Aphantasia — and have the LLM tailor the suggestions and rewrite for that audience.

Instructions

Hints

templates/home.html — new form field above the button:

<div class="mb-3">
  <label class="form-label" for="audience">Target audience</label>
  <select class="form-select" id="audience" name="audience">
    <option value="general">General</option>
    <option value="accessibility">Accessibility (screen readers)</option>
    <option value="adhd">ADHD</option>
    <option value="dyslexia">Dyslexia</option>
    <option value="aphantasia">Aphantasia</option>
  </select>
</div>

pal.py — audience guidance dict + prompt injection:

AUDIENCE_GUIDANCE = {
    "general": "A general adult reader.",
    "accessibility": "Readers using screen readers — avoid visual-only cues, keep link text descriptive, prefer concrete verbs.",
    "adhd": "Readers with ADHD — short chunks, front-load the key point, strong verbs, avoid long subordinate clauses.",
    "dyslexia": "Readers with dyslexia — prefer short common words, break up walls of text, avoid dense punctuation.",
    "aphantasia": "Readers with aphantasia — avoid visual metaphors; describe actions and facts literally.",
}

def get_suggestions(text, stats=None, audience="general"):
    ...
    audience_hint = AUDIENCE_GUIDANCE.get(audience, AUDIENCE_GUIDANCE["general"])
    user_prompt = (
        "... (same prompt as Phase 4) "
        f"\n\nTarget audience: {audience_hint}\n"
        "Tailor suggestions and rewrite for this audience.\n"
        f"{stats_line}\n"
        f"Text:\n{cleaned_text}"
    )
    ...
    return {
        "intent": intent,
        "suggestions": cleaned_suggestions,
        "rewrite": rewrite,
        "score_explanations": score_explanations,
        "audience": audience,
    }

app.py — read and forward:

audience = request.form.get("audience", "general")
stats = get_readability_stats(text)
pal_results = get_suggestions(text, stats, audience)
return render_template(
    "results.html",
    title="Analysis Results",
    text=text,
    pal_results=pal_results,
    stats=stats,
    audience=audience,
)

templates/results.html — badge next to the intent:

<span class="badge text-bg-primary ms-1">{{ pal_results.intent }}</span>
<span class="badge text-bg-secondary ms-1">Audience: {{ audience }}</span>

Done when

Helpful Resources