diff --git a/apps/insights/nlp/explanations.py b/apps/insights/nlp/explanations.py new file mode 100644 index 0000000..d6715cd --- /dev/null +++ b/apps/insights/nlp/explanations.py @@ -0,0 +1,42 @@ +"""Explanation builders for deterministic NLP insights.""" + +from __future__ import annotations + +from apps.insights.nlp.confidence import confidence_label +from apps.insights.nlp.text_processing import meaningful_tokens + + +def build_explanation( + text: str | None, + keywords: list[str], + confidence: int, +) -> str: + """Build a plain-English explanation for a generated insight. + + Args: + text: Raw source text. + keywords: Extracted keyword list. + confidence: Rule-based confidence score. + + Returns: + Explanation suitable for display in the product UI. + """ + tokens = meaningful_tokens(text) + label = confidence_label(confidence) + + if not tokens: + return ( + "This insight has low confidence because the session does not " + "contain enough note text to analyse. Add more detailed notes and " + "generate the insight again." + ) + + keyword_text = ", ".join(keywords) if keywords else "no strong repeated terms" + + return ( + f"This {label.lower()} confidence insight was generated using " + f"{len(tokens)} meaningful terms from the session notes. Keywords were " + f"ranked by term frequency with deterministic tie-breaking. The summary " + f"uses sentences from the original notes rather than generated claims. " + f"Detected keywords: {keyword_text}." + ) diff --git a/apps/insights/tests/test_explanations.py b/apps/insights/tests/test_explanations.py new file mode 100644 index 0000000..be3c445 --- /dev/null +++ b/apps/insights/tests/test_explanations.py @@ -0,0 +1,72 @@ +"""Tests for deterministic insight explanations.""" + +from __future__ import annotations + +from apps.insights.nlp.explanations import build_explanation + + +def test_build_explanation_handles_low_information_text() -> None: + """Blank or low-signal notes should ask the user for more detail.""" + result = build_explanation("", [], confidence=0) + + assert result == ( + "This insight has low confidence because the session does not " + "contain enough note text to analyse. Add more detailed notes and " + "generate the insight again." + ) + + +def test_build_explanation_includes_confidence_label_and_term_count() -> None: + """Meaningful notes should explain confidence using analysed term count.""" + text = "Django testing confirms reliable session workflows." + + result = build_explanation(text, ["django", "testing"], confidence=80) + + assert result.startswith("This high confidence insight") + assert "6 meaningful terms" in result + + +def test_build_explanation_describes_deterministic_keyword_ranking() -> None: + """Explanations should tell users how keywords were selected.""" + result = build_explanation( + "Django testing confirms reliable session workflows.", + ["django", "testing"], + confidence=60, + ) + + assert "ranked by term frequency with deterministic tie-breaking" in result + + +def test_build_explanation_describes_source_sentence_summary() -> None: + """Explanations should state that summaries use source note sentences.""" + result = build_explanation( + "Django testing confirms reliable session workflows.", + ["django", "testing"], + confidence=60, + ) + + assert "uses sentences from the original notes" in result + assert "rather than generated claims" in result + + +def test_build_explanation_lists_detected_keywords() -> None: + """Detected keywords should be visible in the explanation.""" + result = build_explanation( + "Django testing confirms reliable session workflows.", + ["django", "testing"], + confidence=60, + ) + + assert result.endswith("Detected keywords: django, testing.") + + +def test_build_explanation_handles_missing_keywords() -> None: + """Explanations should be honest when there are no strong repeated terms.""" + result = build_explanation( + "Django testing confirms reliable session workflows.", + [], + confidence=45, + ) + + assert result.startswith("This medium confidence insight") + assert result.endswith("Detected keywords: no strong repeated terms.") diff --git a/docs/ai-nlp-contract.md b/docs/ai-nlp-contract.md index 0c92f36..5f51f01 100644 --- a/docs/ai-nlp-contract.md +++ b/docs/ai-nlp-contract.md @@ -2,32 +2,35 @@ ## Purpose -StudyBuddy's study insight feature helps users review notes attached to their -study sessions. The MVP direction is intentionally lightweight: usefulness, +StudyBuddy includes a lightweight AI/NLP feature that helps users review their +study notes. The feature is designed for an early SaaS MVP where usefulness, determinism, explainability, and testability matter more than model complexity. -The system should not use a large language model, external API, background -worker, or opaque prediction service for the first implementation. It should use -deterministic text processing so the same source notes produce the same stored -insight. +The system does not use a large language model, external API, background +worker, or opaque prediction service. It uses deterministic text processing so +the same source notes produce the same insight outputs. -## Current Scope +## Current Project Status -The project currently has the persistence layer for generated insights: +The current project includes: - `apps.insights.models.StudyInsight` -- `apps.insights.apps.InsightsConfig` - `apps.insights.admin.StudyInsightAdmin` -- model, admin, and migration tests +- deterministic text normalisation and source hashing helpers +- deterministic keyword extraction +- extractive summary generation +- rule-based confidence scoring +- deterministic explanation building +- model, admin, and NLP unit tests -The deterministic NLP pipeline, selectors, views, and templates are planned -Sprint 3 work. This document describes the contract those pieces should follow -when they are added. +The end-to-end generation service, selectors, session-detail UI integration, +and insights dashboard are still future integration work. When those pieces are +added, they should follow this contract. ## Product Behaviour -A signed-in user should be able to generate an insight from notes attached to -one of their own study sessions. +A signed-in user should be able to open one of their own study sessions and +generate an insight from the notes attached to that session. The generated insight contains: @@ -35,10 +38,12 @@ The generated insight contains: - ranked keywords - a confidence score from 0 to 100 - an explanation of how the insight was produced -- a source hash representing normalised note text +- a source hash representing the normalised note text -The insight is stored in the database and can be reused later while the source -notes remain unchanged. +The insight is stored in the database and can be viewed again later when the +session or insight UI is wired in. If the source notes have not changed, the +application should reuse the existing insight instead of creating a duplicate +row. ## Deterministic Contract @@ -51,8 +56,8 @@ For the same source note text, the pipeline must produce the same: - confidence score - explanation pattern -The source hash should be generated from normalised note text using SHA-256. The -hash lets the application detect whether notes have changed since the last +The source hash is generated from normalised note text using SHA-256. The hash +allows the application to detect whether notes have changed since the last generated insight. If a user generates an insight twice without changing the notes, StudyBuddy @@ -60,10 +65,10 @@ should reuse the existing insight instead of creating a duplicate row. ## Input Scope -The Sprint 3 implementation should analyse note content attached to a single -study session. +The Sprint 3 implementation analyses note content attached to a single study +session. -It must not analyse: +It does not analyse: - notes from other users - sessions owned by another user @@ -74,15 +79,15 @@ It must not analyse: ## Text Normalisation -The planned pipeline should lowercase text, trim whitespace, collapse repeated -whitespace, and tokenise alphanumeric terms. +The pipeline lowercases text, trims whitespace, collapses repeated whitespace, +and tokenises alphanumeric terms. -Stop words should be filtered before keyword extraction. Short terms should also -be removed where they are unlikely to carry meaningful study context. +Stop words are filtered before keyword extraction. Short terms are also removed +where they are unlikely to carry meaningful study context. ## Keyword Extraction -Keywords should be selected using deterministic term frequency. +Keywords are selected using deterministic term frequency. Ranking rules: @@ -95,22 +100,22 @@ concepts without pretending to infer deep semantic meaning. ## Extractive Summary -The summary should be extractive. It should select sentences from the user's own -notes rather than generating new claims. +The summary is extractive. It selects sentences from the user's own notes +rather than generating new claims. -Sentence scoring should use meaningful tokens and extracted keyword frequency. -High-signal sentences should be selected, then returned in their original source +Sentence scoring uses meaningful tokens and extracted keyword frequency. +High-signal sentences are selected, then returned in their original source order so the summary remains readable. -When there is not enough content, the system should return a low-information -message instead of fabricating a useful-looking summary. +When there is not enough content, the system returns a low-information message +instead of fabricating a useful-looking summary. ## Confidence Scoring -Confidence should be rule-based. It reflects whether the input text contains -enough meaningful content to support a useful insight. +Confidence is rule-based. It reflects whether the input text contains enough +meaningful content to support a useful insight. -The score should consider: +The score considers: - meaningful token count - unique meaningful token count @@ -123,13 +128,12 @@ Confidence labels: - `Medium` for scores from 45 to 74 - `High` for scores from 75 to 100 -The confidence score is not a probability, an intelligence score, or a claim -of factual correctness. It is a quality signal for the generated insight. +The confidence score is not a probability, an intelligence score, or a claim of +factual correctness. It is a quality signal for the generated insight. ## Explanation -Every generated insight should include an explanation. The explanation should -tell the user: +Every generated insight includes an explanation. The explanation tells the user: - that keywords came from deterministic term frequency - that the summary uses source sentences @@ -137,8 +141,8 @@ tell the user: - which keywords were detected - when there was too little content to analyse properly -The explanation is part of the product contract. It avoids presenting the result -as smarter or more authoritative than it is. +The explanation is part of the product contract. It avoids presenting the +result as smarter or more authoritative than it is. ## Persistence Rules @@ -156,13 +160,19 @@ Current fields: - `updated_at` `StudyInsight` does not store a separate `owner` field. Ownership is inherited -through `StudyInsight.session.owner`, matching the existing `StudyNote` +through `StudyInsight.session.owner`, matching the existing session and note ownership model. Uniqueness rule: - one insight per session and source hash +Ownership rule: + +- insight ownership is resolved through the parent session +- any generation service or view must ensure the session belongs to the + requesting user before creating or returning an insight + Validation rules currently enforced by the model: - `keywords` must be a list @@ -170,8 +180,8 @@ Validation rules currently enforced by the model: - `confidence` must be between 0 and 100 - `source_hash` must be 64 characters -The planned NLP pipeline should generate `source_hash` values as valid SHA-256 -hex digests before creating a `StudyInsight`. +The NLP pipeline should generate `source_hash` values as valid SHA-256 hex +digests before creating a `StudyInsight`. ## Permission Rules @@ -193,18 +203,18 @@ Current tests cover: - duplicate protection for `session` and `source_hash` - admin owner display through the parent session - admin keyword preview behaviour - -Planned Sprint 3 tests should cover: - - text normalisation - source hashing - keyword extraction -- summary generation +- extractive summary generation - confidence scoring - explanation behaviour -- idempotent insight generation -- permission enforcement -- session detail or insights UI visibility, when those views exist + +Future integration tests should cover: + +- idempotent insight generation through a service layer +- permission enforcement for generation and retrieval +- session detail or insights dashboard visibility, when those views exist ## Known Limitations @@ -221,6 +231,7 @@ Current limitations: - no personalised recommendations - no support for uploaded files - no multilingual NLP tuning +- no end-to-end insight generation UI yet These limitations are acceptable for the Sprint 3 MVP because the feature is deterministic, cheap to run, easy to test, and honest in the UI. diff --git a/docs/sprint-runbook/sprint-3/sprint-3-day-3.sh b/docs/sprint-runbook/sprint-3/sprint-3-day-3.sh new file mode 100755 index 0000000..7232bc7 --- /dev/null +++ b/docs/sprint-runbook/sprint-3/sprint-3-day-3.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash + +# Sprint 3 Wednesday Console-Only Verification Runbook +# +# Purpose: +# Verify Sprint 3 Wednesday: extractive summaries, confidence scoring, and +# user-facing NLP explanations. +# +# Execution: +# From the repository root: +# +# chmod +x docs/sprint-runbook/sprint-3/sprint-3-day-3.sh +# ./docs/sprint-runbook/sprint-3/sprint-3-day-3.sh +# +# Optional environment overrides: +# +# PROJECT_ROOT=/path/to/repo \ +# TEST_SETTINGS_MODULE=config.settings.test \ +# LOCAL_SETTINGS_MODULE=config.settings.local \ +# ./docs/sprint-runbook/sprint-3/sprint-3-day-3.sh + +set -euo pipefail + +PROJECT_ROOT="${PROJECT_ROOT:-/Users/adrianadewunmi/VSCODE/StudyBuddy-Study-Planner-Project}" +TEST_SETTINGS_MODULE="${TEST_SETTINGS_MODULE:-config.settings.test}" +LOCAL_SETTINGS_MODULE="${LOCAL_SETTINGS_MODULE:-config.settings.local}" + +print_step() { + printf "\n==> %s\n\n" "$1" +} + +run() { + printf '$' + printf ' %q' "$@" + printf '\n' + "$@" +} + +print_step "Verify repository root" +run cd "$PROJECT_ROOT" +printf "Repository root: %s\n" "$(pwd)" + +print_step "Confirm Sprint 3 Day 3 files exist" + +required_files=( + "apps/insights/nlp/summarisation.py" + "apps/insights/nlp/confidence.py" + "apps/insights/nlp/explanations.py" + "apps/insights/tests/test_summarisation.py" + "apps/insights/tests/test_confidence.py" + "apps/insights/tests/test_explanations.py" + "docs/ai-nlp-contract.md" +) + +for file_path in "${required_files[@]}"; do + if [[ ! -f "$file_path" ]]; then + printf "MISSING: %s\n" "$file_path" + exit 1 + fi + + printf "FOUND: %s\n" "$file_path" +done + +print_step "Build and start Docker/PostgreSQL stack" +run docker compose up -d --build +run docker compose ps + +print_step "Run Django system check" +run docker compose exec -T web python manage.py check --settings="$LOCAL_SETTINGS_MODULE" + +print_step "Confirm summarisation, confidence, and explanation modules import correctly" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import confidence_label, score_confidence +from apps.insights.nlp.explanations import build_explanation +from apps.insights.nlp.summarisation import LOW_INFORMATION_SUMMARY, summarise_text + +print("summarisation imports verified") +print("confidence imports verified") +print("explanations imports verified") +print("Sprint 3 Day 3 NLP module import verification complete") +PY + +print_step "Verify extractive summary selects a high-signal source sentence" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.summarisation import summarise_text + +text = ( + "Today I opened the dashboard. " + "Django testing confirms that Django views and Django forms work. " + "I also reviewed a short note." +) + +result = summarise_text(text, max_sentences=1) +expected = "Django testing confirms that Django views and Django forms work." + +assert result == expected, result + +print("Summary:", result) +print("High-signal extractive summary verified") +PY + +print_step "Verify extractive summary preserves source order" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.summarisation import summarise_text + +text = ( + "Database database transactions keep study notes consistent. " + "Django forms validate study sessions. " + "Database database constraints protect ownership rules." +) + +result = summarise_text(text, max_sentences=2) +expected = ( + "Database database transactions keep study notes consistent. " + "Database database constraints protect ownership rules." +) + +assert result == expected, result + +print("Source-ordered summary:", result) +print("Source-order preservation verified") +PY + +print_step "Verify extractive summary only uses source note sentences" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.summarisation import summarise_text +from apps.insights.nlp.text_processing import split_sentences + +text = ( + "Photosynthesis photosynthesis uses chlorophyll to convert light into glucose. " + "Cell respiration releases stored energy during revision. " + "Photosynthesis depends on carbon dioxide and water." +) + +result = summarise_text(text, max_sentences=2) +source_sentences = split_sentences(text) +summary_sentences = split_sentences(result) + +assert summary_sentences, result +assert all(sentence in source_sentences for sentence in summary_sentences), result +assert "mitochondria" not in result, result + +print("Grounded summary:", result) +print("Source-sentence grounding verified") +PY + +print_step "Verify low-information summary handling" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.summarisation import LOW_INFORMATION_SUMMARY, summarise_text + +empty_result = summarise_text("") +none_result = summarise_text(None) +zero_sentence_result = summarise_text("Django testing workflow.", max_sentences=0) + +assert empty_result == LOW_INFORMATION_SUMMARY, empty_result +assert none_result == LOW_INFORMATION_SUMMARY, none_result +assert zero_sentence_result == LOW_INFORMATION_SUMMARY, zero_sentence_result + +print("Empty input summary:", empty_result) +print("None input summary:", none_result) +print("Zero-sentence summary:", zero_sentence_result) +print("Low-information summary handling verified") +PY + +print_step "Verify summary generation is stable across repeated runs" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.summarisation import summarise_text + +text = ( + "Django tests protect session workflows. " + "Database constraints protect ownership behaviour. " + "Django forms validate user input. " + "Pytest confirms persistence and permissions." +) + +first = summarise_text(text, max_sentences=2) +second = summarise_text(text, max_sentences=2) +third = summarise_text(text, max_sentences=2) + +assert first == second == third, (first, second, third) + +print("Repeated summary one:", first) +print("Repeated summary two:", second) +print("Repeated summary three:", third) +print("Deterministic summarisation verified") +PY + +print_step "Verify confidence score returns zero for empty input" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import score_confidence + +result = score_confidence("", [], "There is not enough content.") + +assert result == 0, result + +print("Empty input confidence:", result) +print("Zero-confidence handling verified") +PY + +print_step "Verify confidence score stays within bounds" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import score_confidence + +text = " ".join(["django testing database workflow"] * 20) + +result = score_confidence( + text, + ["django", "testing", "database", "workflow"], + "Django testing database workflow.", +) + +assert 0 <= result <= 100, result + +print("Bounded confidence:", result) +print("Confidence bounds verified") +PY + +print_step "Verify confidence scoring is repeatable" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import score_confidence + +text = ( + "Django testing confirms reliable session workflows. " + "Database-backed notes improve review quality." +) +keywords = ["django", "testing", "database"] +summary = "Django testing confirms reliable session workflows." + +scores = [score_confidence(text, keywords, summary) for _ in range(5)] + +assert len(set(scores)) == 1, scores + +print("Repeated confidence scores:", scores) +print("Deterministic confidence scoring verified") +PY + +print_step "Verify richer note content scores higher than weak note content" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import score_confidence + +weak = score_confidence("Django.", ["django"], "Django.") +strong = score_confidence( + ( + "Django testing confirms reliable session workflows. " + "Database-backed notes improve review quality. " + "Pytest verifies permissions and persistence behaviour." + ), + ["django", "testing", "database", "pytest", "permissions"], + "Django testing confirms reliable session workflows.", +) + +assert strong > weak, (weak, strong) + +print("Weak confidence:", weak) +print("Strong confidence:", strong) +print("Confidence quality progression verified") +PY + +print_step "Verify low-information summary is not rewarded as usable" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import score_confidence +from apps.insights.nlp.summarisation import LOW_INFORMATION_SUMMARY + +text = ( + "Django testing confirms reliable session workflows. " + "Database-backed notes improve review quality." +) +keywords = ["django", "testing", "database"] + +with_fallback_summary = score_confidence(text, keywords, LOW_INFORMATION_SUMMARY) +with_extract_summary = score_confidence( + text, + keywords, + "Django testing confirms reliable session workflows.", +) + +assert with_extract_summary > with_fallback_summary, ( + with_fallback_summary, + with_extract_summary, +) + +print("Fallback-summary confidence:", with_fallback_summary) +print("Extractive-summary confidence:", with_extract_summary) +print("Low-information summary confidence penalty verified") +PY + +print_step "Verify confidence labels" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.confidence import confidence_label + +assert confidence_label(0) == "Low" +assert confidence_label(44) == "Low" +assert confidence_label(45) == "Medium" +assert confidence_label(74) == "Medium" +assert confidence_label(75) == "High" +assert confidence_label(100) == "High" + +print("0 ->", confidence_label(0)) +print("44 ->", confidence_label(44)) +print("45 ->", confidence_label(45)) +print("74 ->", confidence_label(74)) +print("75 ->", confidence_label(75)) +print("100 ->", confidence_label(100)) +print("Confidence label boundaries verified") +PY + +print_step "Verify explanation for useful note content" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.explanations import build_explanation + +text = ( + "Django testing confirms reliable session workflows. " + "Database-backed notes improve review quality." +) + +result = build_explanation( + text=text, + keywords=["django", "testing", "database"], + confidence=72, +) + +lower_result = result.lower() + +assert "medium confidence" in lower_result, result +assert "meaningful terms" in lower_result, result +assert "term frequency" in lower_result, result +assert "deterministic tie-breaking" in lower_result, result +assert "original notes" in lower_result, result +assert "rather than generated claims" in lower_result, result +assert "django, testing, database" in lower_result, result + +print("Explanation:", result) +print("Useful-content explanation verified") +PY + +print_step "Verify explanation for low-information note content" + +docker compose exec -T web python manage.py shell --settings="$LOCAL_SETTINGS_MODULE" <<'PY' +from apps.insights.nlp.explanations import build_explanation + +result = build_explanation( + text="", + keywords=[], + confidence=0, +) + +lower_result = result.lower() + +assert "low confidence" in lower_result, result +assert "not contain enough note text" in lower_result, result +assert "add more detailed notes" in lower_result, result + +print("Low-information explanation:", result) +print("Low-information explanation verified") +PY + +print_step "Confirm AI/NLP contract document sections" + +required_sections=( + "## Current Project Status" + "## Deterministic Contract" + "## Extractive Summary" + "## Confidence Scoring" + "## Explanation" + "## Testing Contract" +) + +for section in "${required_sections[@]}"; do + if ! grep -Fq "$section" docs/ai-nlp-contract.md; then + printf "MISSING SECTION: %s\n" "$section" + exit 1 + fi + + printf "FOUND: %s\n" "$section" +done + +print_step "Confirm AI/NLP contract includes Wednesday rules" + +required_contract_phrases=( + "The summary is extractive." + "rather than generating new claims" + "When there is not enough content" + "Confidence is rule-based." + "The confidence score is not a probability" + "Every generated insight includes an explanation." + "The explanation is part of the product contract." + "explanation behaviour" +) + +for phrase in "${required_contract_phrases[@]}"; do + if ! grep -Fq "$phrase" docs/ai-nlp-contract.md; then + printf "MISSING CONTRACT PHRASE: %s\n" "$phrase" + exit 1 + fi + + printf "FOUND: %s\n" "$phrase" +done + +print_step "Run Sprint 3 Day 3 NLP summarisation, confidence, and explanation tests" + +run docker compose exec -T web env DJANGO_SETTINGS_MODULE="$TEST_SETTINGS_MODULE" pytest \ + apps/insights/tests/test_summarisation.py \ + apps/insights/tests/test_confidence.py \ + apps/insights/tests/test_explanations.py \ + -q + +print_step "Run all current insights tests" +run docker compose exec -T web env DJANGO_SETTINGS_MODULE="$TEST_SETTINGS_MODULE" pytest apps/insights -q + +print_step "Run full project test suite" +run docker compose exec -T web env DJANGO_SETTINGS_MODULE="$TEST_SETTINGS_MODULE" pytest -q + +print_step "Final receipt" + +cat <<'RECEIPT' +Repository root verified. +Sprint 3 Day 3 files verified. +Docker/PostgreSQL stack is running. +Django system check passed. +Summarisation, confidence, and explanation modules import correctly. +High-signal extractive summary verified. +Source-order summary preservation verified. +Source-sentence grounding verified. +Low-information summary handling verified. +Deterministic repeated-run summarisation verified. +Zero-confidence handling verified. +Confidence bounds verified. +Deterministic confidence scoring verified. +Confidence quality progression verified. +Low-information summary confidence penalty verified. +Confidence label boundaries verified. +Useful-content explanation verified. +Low-information explanation verified. +AI/NLP contract document headings verified. +AI/NLP contract Wednesday rules verified. +Sprint 3 Day 3 NLP summarisation, confidence, and explanation tests pass. +Current insights tests pass. +Full project regression suite passes. +Sprint 3 Day 3 verification complete. +RECEIPT