> Source URL: /unit-3/project-paths/miranda-m/miranda-m-2026-04-23.guide
# 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

- [ ] Run `uv add python-dotenv`
- [ ] Create `.env` at the project root with `ANTHROPIC_API_KEY=sk-ant-...`
- [ ] Confirm `.env` is listed in `.gitignore`
- [ ] Create `.env.example` with `ANTHROPIC_API_KEY=` (empty) and commit it
- [ ] In `app.py`, call `load_dotenv()` before importing `pal`

### Hints

**`.env.example` (committed):**

```text
ANTHROPIC_API_KEY=
```

**Top of `app.py`:**

```python
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

- [ ] In `templates/home.html`, give the `<form>` and `<button>` IDs
- [ ] Replace the button label with a label span + a hidden Bootstrap spinner span
- [ ] Add a small `<script>` at the bottom of the template that disables the button and shows the spinner on submit

### Hints

**Updated button:**

```html
<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:**

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

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

```html
<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

- [ ] In `pal.py`, update the system + user prompts to also return a `rewrite` field
- [ ] In `pal.py`, read `rewrite` off the parsed JSON with a safe default (`""`)
- [ ] In `templates/results.html`, add a "Suggested rewrite" section under Accessibility suggestions with a read-only `<textarea>`

### Hints

**`pal.py` — update prompts:**

```python
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`:**

```python
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:**

```html
<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

- [ ] In `app.py`, compute `stats` before calling `get_suggestions`, and pass `stats` in: `get_suggestions(text, stats)`
- [ ] In `pal.py`, update `get_suggestions` to accept `stats` and mention them in the user prompt
- [ ] Extend the JSON shape to include `score_explanations` — a dict keyed by `flesch_ease`, `avg_sentence_length`, `long_sentences`, `complex_words`
- [ ] In `templates/results.html`, show each explanation under its stat

### Hints

**`app.py` — reorder the two calls:**

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

**`pal.py` — new signature + prompt addition:**

```python
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`:**

```python
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:**

```html
<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

- [ ] In `templates/home.html`, add a `<select name="audience">` above the Analyze button with the five options
- [ ] In `app.py`, read `audience = request.form.get("audience", "general")` and pass it to `get_suggestions(text, stats, audience)`
- [ ] In `pal.py`, accept `audience`, map it through a small dict of guidance sentences, and inject into the user prompt
- [ ] In `templates/results.html`, show the selected audience as a small badge next to the intent

### Hints

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

```html
<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:**

```python
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:**

```python
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:**

```html
<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

- [ ] `ANTHROPIC_API_KEY` is loaded from `.env`; `.env` is gitignored; `.env.example` is committed
- [ ] Clicking Analyze disables the button and shows a spinner until the results page loads
- [ ] Results page shows a "Suggested rewrite" textarea with the rewritten text
- [ ] Each readability stat has a one-sentence LLM-written explanation under it
- [ ] (Bonus) Picking a different audience changes the suggestions and the rewrite
- [ ] Checkpoint 3 entry in `project.journal.md`
- [ ] Committed and pushed

## Helpful Resources

- [Checkpoint 3 Instructions](../../projects/final-project-checkpoint-3.project.md)
- [python-dotenv docs](https://pypi.org/project/python-dotenv/)
- [Bootstrap spinners](https://getbootstrap.com/docs/5.3/components/spinners/)
- [Anthropic Messages API](https://docs.anthropic.com/en/api/messages)


---

## Backlinks

The following sources link to this document:

- [April 23 -- Checkpoint 3 (Polish + Rewrite)](/unit-3/project-paths/miranda-m/miranda-m.path.llm.md)
