From 679561bcdbedc8c78cec8aff3625b81d946b93a8 Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Fri, 13 Feb 2026 09:14:04 -0500 Subject: [PATCH] First deployment --- .env | 4 +- .github/workflows/quality-gates.yml | 37 + .sisyphus/notepads/sync-p12-p16/decisions.md | 5 + .sisyphus/notepads/sync-p12-p16/issues.md | 3 + .sisyphus/notepads/sync-p12-p16/learnings.md | 5 + .sisyphus/notepads/sync-p12-p16/problems.md | 3 + README.md | 14 + backend/__pycache__/cli.cpython-313.pyc | Bin 22669 -> 25541 bytes backend/__pycache__/config.cpython-313.pyc | Bin 2138 -> 2289 bytes backend/__pycache__/main.cpython-313.pyc | Bin 9223 -> 14423 bytes .../__pycache__/news_service.cpython-313.pyc | Bin 34652 -> 39301 bytes backend/cli.py | 141 ++- backend/main.py | 20 +- backend/news_service.py | 138 ++- docs/monitoring-dashboard-config.md | 26 + docs/p15-code-review-findings.md | 23 + docs/quality-and-monitoring.md | 64 + frontend/index.html | 306 ++++- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/hero-display/spec.md | 0 .../specs/summary-analytics-tagging/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../spec.md | 0 .../spec.md | 0 .../specs/footer-policy-links/spec.md | 0 .../language-aware-content-delivery/spec.md | 0 .../responsive-device-agnostic-layout/spec.md | 0 .../specs/seo-meta-and-social-tags/spec.md | 0 .../spec.md | 0 .../specs/site-branding-favicon/spec.md | 0 .../specs/summary-modal-experience/spec.md | 0 .../tasks.md | 0 .../2026-02-13-p14-bugs}/.openspec.yaml | 0 .../archive/2026-02-13-p14-bugs/design.md | 58 + .../2026-02-13-p14-bugs}/proposal.md | 0 .../spec.md | 19 + .../specs/footer-policy-links/spec.md | 14 + .../specs/hero-display/spec.md | 16 + .../spec.md | 14 + .../responsive-device-agnostic-layout/spec.md | 14 + .../spec.md | 22 + .../archive/2026-02-13-p14-bugs/tasks.md | 30 + .../.openspec.yaml | 0 .../design.md | 55 + .../proposal.md | 0 .../code-review-remediation-workflow/spec.md | 17 + .../spec.md | 43 + .../specs/end-to-end-system-testing/spec.md | 16 + .../spec.md | 16 + .../specs/platform-quality-gates/spec.md | 17 + .../spec.md | 17 + .../site-admin-safety-and-ergonomics/spec.md | 33 + .../specs/wcag-2-2-aa-accessibility/spec.md | 19 + .../tasks.md | 43 + .../2026-02-13-p16-more-bugs/.openspec.yaml | 2 + .../2026-02-13-p16-more-bugs/design.md | 100 ++ .../2026-02-13-p16-more-bugs/proposal.md | 46 + .../admin-maintenance-command-suite/spec.md | 9 + .../spec.md | 25 + .../specs/article-translations-ml-tm/spec.md | 19 + .../specs/attribution-disclaimer-page/spec.md | 14 + .../spec.md | 17 + .../specs/footer-policy-links/spec.md | 14 + .../specs/hero-display/spec.md | 9 + .../spec.md | 14 + .../language-aware-content-delivery/spec.md | 14 + .../spec.md | 14 + .../permalink-targeted-image-refetch/spec.md | 14 + .../specs/policy-disclosure-modals/spec.md | 27 + .../queued-image-refetch-with-backoff/spec.md | 14 + .../responsive-device-agnostic-layout/spec.md | 14 + .../terms-of-use-risk-disclosure/spec.md | 14 + .../spec.md | 22 + .../specs/wcag-2-2-aa-accessibility/spec.md | 14 + .../archive/2026-02-13-p16-more-bugs/tasks.md | 43 + .../admin-maintenance-command-suite/spec.md | 3 +- .../spec.md | 29 + .../spec.md | 39 + .../specs/article-translations-ml-tm/spec.md | 7 +- .../specs/attribution-disclaimer-page/spec.md | 18 +- .../spec.md | 9 +- .../spec.md | 8 + .../specs/end-to-end-system-testing/spec.md | 20 + openspec/specs/footer-policy-links/spec.md | 31 +- openspec/specs/hero-display/spec.md | 19 +- .../spec.md | 4 +- .../language-aware-content-delivery/spec.md | 12 +- .../spec.md | 19 +- .../permalink-targeted-image-refetch/spec.md | 18 + openspec/specs/platform-quality-gates/spec.md | 21 + .../specs/policy-disclosure-modals/spec.md | 31 + .../queued-image-refetch-with-backoff/spec.md | 9 +- .../responsive-device-agnostic-layout/spec.md | 19 +- .../spec.md | 21 + .../specs/seo-meta-and-social-tags/spec.md | 5 + .../spec.md | 47 + .../site-admin-safety-and-ergonomics/spec.md | 8 + .../specs/summary-analytics-tagging/spec.md | 2 + .../specs/summary-modal-experience/spec.md | 8 + .../terms-of-use-risk-disclosure/spec.md | 16 +- .../spec.md | 26 + .../specs/wcag-2-2-aa-accessibility/spec.md | 11 +- pyproject.toml | 1 + pytest.ini | 9 + .../conftest.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 3680 bytes ...lity_contract.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6655 bytes ...api_contracts.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 11417 bytes ..._db_workflows.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 4299 bytes ...ical_journeys.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 9236 bytes ...2e_edge_cases.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 5224 bytes ...n_and_refetch.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 13472 bytes ...d_performance.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 4635 bytes ...ag_regression.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 6358 bytes tests/conftest.py | 69 ++ tests/test_accessibility_contract.py | 19 + tests/test_api_contracts.py | 43 + tests/test_db_workflows.py | 44 + tests/test_e2e_critical_journeys.py | 30 + tests/test_e2e_edge_cases.py | 20 + tests/test_p16_translation_and_refetch.py | 71 ++ tests/test_security_and_performance.py | 16 + tests/test_wcag_regression.py | 23 + uv.lock | 1083 +++++++++++++++++ 128 files changed, 3479 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/quality-gates.yml create mode 100644 .sisyphus/notepads/sync-p12-p16/decisions.md create mode 100644 .sisyphus/notepads/sync-p12-p16/issues.md create mode 100644 .sisyphus/notepads/sync-p12-p16/learnings.md create mode 100644 .sisyphus/notepads/sync-p12-p16/problems.md create mode 100644 docs/monitoring-dashboard-config.md create mode 100644 docs/p15-code-review-findings.md create mode 100644 docs/quality-and-monitoring.md rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/.openspec.yaml (100%) rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/design.md (100%) rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/proposal.md (100%) rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/specs/hero-display/spec.md (100%) rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/specs/summary-analytics-tagging/spec.md (100%) rename openspec/changes/{p12-umami-events-and-more => archive/2026-02-13-p12-umami-events-and-more}/tasks.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/.openspec.yaml (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/design.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/proposal.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/article-permalinks-and-deep-link-modal/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/error-pages-with-playful-ai-messaging/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/footer-policy-links/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/language-aware-content-delivery/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/responsive-device-agnostic-layout/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/seo-meta-and-social-tags/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/share-and-contact-microinteractions/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/site-branding-favicon/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/specs/summary-modal-experience/spec.md (100%) rename openspec/changes/{p13-usability-enhancements => archive/2026-02-13-p13-usability-enhancements}/tasks.md (100%) rename openspec/changes/{p14-bugs => archive/2026-02-13-p14-bugs}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/design.md rename openspec/changes/{p14-bugs => archive/2026-02-13-p14-bugs}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/article-permalinks-and-deep-link-modal/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/footer-policy-links/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/hero-display/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/news-image-relevance-and-fallbacks/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/responsive-device-agnostic-layout/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/specs/share-and-contact-microinteractions/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p14-bugs/tasks.md rename openspec/changes/{p15-complete-test-suite => archive/2026-02-13-p15-complete-test-suite}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/design.md rename openspec/changes/{p15-complete-test-suite => archive/2026-02-13-p15-complete-test-suite}/proposal.md (100%) create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/code-review-remediation-workflow/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/delivery-and-rendering-performance/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/end-to-end-system-testing/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/observability-monitoring-and-alerting/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/platform-quality-gates/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/security-and-performance-test-harness/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/site-admin-safety-and-ergonomics/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/specs/wcag-2-2-aa-accessibility/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p15-complete-test-suite/tasks.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/design.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/proposal.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/admin-maintenance-command-suite/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/alternative-image-selection-and-dedupe/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/article-translations-ml-tm/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/attribution-disclaimer-page/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/context-aware-image-selection-recovery/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/footer-policy-links/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/hero-display/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/hero-summary-entry-and-readability/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/language-aware-content-delivery/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/news-image-relevance-and-fallbacks/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/permalink-targeted-image-refetch/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/policy-disclosure-modals/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/queued-image-refetch-with-backoff/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/responsive-device-agnostic-layout/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/terms-of-use-risk-disclosure/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/translation-quality-validation-gates/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/specs/wcag-2-2-aa-accessibility/spec.md create mode 100644 openspec/changes/archive/2026-02-13-p16-more-bugs/tasks.md create mode 100644 openspec/specs/alternative-image-selection-and-dedupe/spec.md create mode 100644 openspec/specs/article-permalinks-and-deep-link-modal/spec.md create mode 100644 openspec/specs/end-to-end-system-testing/spec.md create mode 100644 openspec/specs/permalink-targeted-image-refetch/spec.md create mode 100644 openspec/specs/platform-quality-gates/spec.md create mode 100644 openspec/specs/policy-disclosure-modals/spec.md create mode 100644 openspec/specs/security-and-performance-test-harness/spec.md create mode 100644 openspec/specs/share-and-contact-microinteractions/spec.md create mode 100644 openspec/specs/translation-quality-validation-gates/spec.md create mode 100644 pytest.ini create mode 100644 tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_accessibility_contract.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_api_contracts.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_db_workflows.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_e2e_critical_journeys.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_e2e_edge_cases.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_p16_translation_and_refetch.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_security_and_performance.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_wcag_regression.cpython-313-pytest-9.0.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_accessibility_contract.py create mode 100644 tests/test_api_contracts.py create mode 100644 tests/test_db_workflows.py create mode 100644 tests/test_e2e_critical_journeys.py create mode 100644 tests/test_e2e_edge_cases.py create mode 100644 tests/test_p16_translation_and_refetch.py create mode 100644 tests/test_security_and_performance.py create mode 100644 tests/test_wcag_regression.py create mode 100644 uv.lock diff --git a/.env b/.env index 4cb0207..4d358da 100644 --- a/.env +++ b/.env @@ -10,4 +10,6 @@ ROYALTY_IMAGE_PROVIDERS=pixabay,unsplash,pexels,wikimedia,picsum PIXABAY_API_KEY=54637577-dbef68c927eec6553190fa4dc UNSPLASH_ACCESS_KEY= PEXELS_API_KEY=fRdPmXg16nsz1pPe0Zmp02eALJkhAz4sG7g4RN56Q3J90Qi6qV3Qvuz8 -SUMMARY_LENGTH_SCALE=3 \ No newline at end of file +SUMMARY_LENGTH_SCALE=3 +GITHUB_REPO_URL=https://github.com/santhoshjanan +CONTACT_EMAIL=santhoshj@gmail.com \ No newline at end of file diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..04082a0 --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,37 @@ +name: quality-gates + +on: + pull_request: + push: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install project dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + - name: Ruff lint + run: python -m ruff check backend tests + - name: Pytest coverage and contracts + run: python -m pytest + + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install scanner + run: | + python -m pip install --upgrade pip + pip install pip-audit + - name: Dependency vulnerability scan + run: pip-audit diff --git a/.sisyphus/notepads/sync-p12-p16/decisions.md b/.sisyphus/notepads/sync-p12-p16/decisions.md new file mode 100644 index 0000000..70318a4 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/decisions.md @@ -0,0 +1,5 @@ +## 2026-02-13 + +- Applied chronological conflict resolution order exactly as requested: p12 -> p13 -> p14 -> p15 -> p16, with p16 language/behavior taking precedence on overlapping requirement text. +- Created new capability specs in canonical form for all delta-marked additions missing from `openspec/specs/*`. +- Kept pre-existing requirements/scenarios unless superseded by explicit newer behavioral changes (e.g., footer policy navigation replaced with modal disclosure behavior). diff --git a/.sisyphus/notepads/sync-p12-p16/issues.md b/.sisyphus/notepads/sync-p12-p16/issues.md new file mode 100644 index 0000000..cabfc13 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/issues.md @@ -0,0 +1,3 @@ +## 2026-02-13 + +- Markdown LSP diagnostics are unavailable in this workspace (`No LSP server configured for extension: .md`), so spec-file verification relied on structural diff review instead of LSP validation. diff --git a/.sisyphus/notepads/sync-p12-p16/learnings.md b/.sisyphus/notepads/sync-p12-p16/learnings.md new file mode 100644 index 0000000..38afce8 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/learnings.md @@ -0,0 +1,5 @@ +## 2026-02-13 + +- Canonical OpenSpec sync files follow a stable template: `## Purpose` + `## Requirements`; preserving this structure keeps delta-to-main merges consistent. +- Conflict-heavy capability merges are safest when newest delta text updates requirement sentence semantics while older non-conflicting scenarios are retained. +- For policy/footer capabilities, behavior precedence can flip navigation contracts (page routes -> modals) while still preserving visibility and accessibility scenarios from older deltas. diff --git a/.sisyphus/notepads/sync-p12-p16/problems.md b/.sisyphus/notepads/sync-p12-p16/problems.md new file mode 100644 index 0000000..1c2abd3 --- /dev/null +++ b/.sisyphus/notepads/sync-p12-p16/problems.md @@ -0,0 +1,3 @@ +## 2026-02-13 + +- None unresolved in the p12-p16 spec merge scope. diff --git a/README.md b/README.md index 26623b3..42c0d60 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,20 @@ Exit codes: - `0`: Command completed successfully (including runs that store zero new rows) - `1`: Fatal command failure (for example missing API keys or unrecoverable runtime error) +## Quality and Test Suite + +Run local quality gates: + +```bash +pip install -e .[dev] +pytest +ruff check backend tests +``` + +CI quality gates are defined in `.github/workflows/quality-gates.yml`. + +Monitoring baseline, thresholds, and alert runbook are documented in `docs/quality-and-monitoring.md`. + ## Admin Maintenance Commands ClawFort includes an admin command suite to simplify operational recovery and maintenance. diff --git a/backend/__pycache__/cli.cpython-313.pyc b/backend/__pycache__/cli.cpython-313.pyc index 8ad57c966edcb363f11200f5723193f63ca9b348..e95c475803c1dbab1a65df98a3c9739b50c74ae5 100644 GIT binary patch delta 8347 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 bLen`Xan8_RVRTtQuYq@RIQxGVQ=obP^(j>t delta 359 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 delta 3087 zcma)8OK{uf5e5hn)Ptm~he?roka}8_EK(23He*??YDu<1SyJ>nQ!8PIp-D)BOaaUS zv?EVDa+9=an>LLH?MyB`d3wlnrWf|mOU^y?prIK!M9}8OHS!7_|U^T1T*}w z|Gxg+|KDBkS@FXgz5}nfiNj~?dmk$|f{%Tj`w17v{f1*4<5L_|-aPQQcBCAV078U# zLdq$*z$J+wN^Wr5JZGvwYJ^6ccBPu6W@xr)G3AlG;I(OY$|tozi%mDA&Pc7$YSWFW zHmM!jr4HEduo+FMvyvbDHr<>GNS)AW)1FjN3PDKff-b2Wx}_fIk$Rz53PV_mKt$?; zK3mV5>X!y!KpKQWX$XcQT$)>7rbLfF{ls56$}@i(a4SC6l62TMYe!`j8f~I8e1(S5 zwAjzFGpv=hB^@x99$Vjd)gs-<+T%Ryu#u>1OA`;qhz>q|P)Zafp7^|OI0J;;XGFguqNs*;hqB+5o@ z+J!N<*LFy_*jPHr_;EMKvGYic*H^LhYb>5_VWIR;!owygjvc-htq7NEp|HvHVAVk_ zpwqWa>%;{o&4pav37mN;R1<=b3z;Ixab7QxJEvU(BJ z4vo#!i(#%7?ZAb)G?exx9PHw;i@RFayL3V?c}(v+b-mY4=v_XhH(%GAJ)-wr-1o{c zy=z;1^s4z#>}7)GUXY8jn%6D&QdT$S*KS&_*Gn?&MS072ZFQa9Qu6tN{2&W5a+;Zu zHI&?vQjm3AdWcw;^`fTgBz7}d*UQuH$Gn+8yJWV7h8rvqWTOOX`BG?ypB=>}fHMTm z2$pYs@%G}%?VGDBnT7cc2Cc*t3)}7HVIjfe6Z~>i6mP6fTho8u(U^%z75?JD{qLvI9uA2YuoeSCf zx2-L%uPra$xp{jdgPCViiyII(FNcGxUbKV>3a3%eZOi#m0V8r}^ZCqnK?^{e8dSXU!*lKj5dzk^T+G=q$3M0??>~HxaBR`Q2Qhl$SF~{^d9Lz#Zg% z=EG#44E~-^o2x^^`)5hJowS95tin}Vb`bap0wfT#MJfge5+_HHCy)sgf*pc;1O42H-Wnd}`Q`G*=RW86DPedUA?gD=(>7w*`PrG)ll->HMVk&20U&1v zL5bki1On9z=wp7E0L+;GiUwWpBJ%_Do9Gw(viZg6%QHQ=#<~=-QjC%gVe>Cz!CO9L zTH>Zsz#4^VS_q1s!3NMuSVo;>;;>H@9wDvMhbI!LoJ|v*&9sMa7pF&K*rrEl*7?lULx?s+8froT;h6&(Qi0 zoVh(7Z+IxiG2UGrUB7RJW6kqFCif?*x>>jFZAJJcDwIj-=L8=iJoLn~h5^dmlA&lS zdV0_NPpobHG*7J+Pe;_@SLoqSoH-C5;Xg8$wD<9MP%yQ4&W|5LN6b0MT@*I? zsIX#pM$~BzP7ImK#5utU&89gqvfs|$SX*0NzrDDSS)O0H{>J?EMRqa|Pi6OF*S>1sq+wdoc}mf$Gw7SBIxPePJ9~;34qZ7FA(vSIf%<9qfASs4=PQ zGL*iOlkNFu-D#`IKrU*!VrZ~eneEiYV7DJln@SJbEm`lu`WTPVbluJBc70bmGi1i5 z&qwG?Edj@o<)f*4cmnVf^7*0p=jqXXd!Sw*%?7PDF>jHW#-=6Uw7?!x5p)CWkpa|+ zc8kDIiK0Tb3(~G4%b8blhJCLmZ@ys`PX#{$bMQwrp+BE`2>;Y^UkW_WKj(bUxyJu; z^Z($+o^oR^xTzO}qc6CA^Vf;Dgnx2^`NNr!^519Pb_7LU{B+@f!`FepIXb>_AZ335 F{tw#*v&{ei diff --git a/backend/__pycache__/news_service.cpython-313.pyc b/backend/__pycache__/news_service.cpython-313.pyc index 0a9dae9ab2668bdc7c68ce508df41298ac222d63..d36f2e66b5b97ff0cf5529edf301db8a169069ac 100644 GIT binary patch delta 11329 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 S*z*7@=DV5U)iYYOhW`glgtp@V diff --git a/backend/cli.py b/backend/cli.py index 688e1f5..94e447f 100644 --- a/backend/cli.py +++ b/backend/cli.py @@ -14,6 +14,7 @@ from backend import config from backend.database import SessionLocal, init_db from backend.models import NewsItem from backend.news_service import ( + GENERIC_AI_FALLBACK_URL, download_and_optimize_image, extract_image_keywords, fetch_royalty_free_image, @@ -87,56 +88,105 @@ def build_contextual_query(headline: str, summary: str | None) -> str: return cleaned +def resolve_article_id_from_permalink(value: str | None) -> int | None: + if not value: + return None + if value.isdigit(): + return int(value) + match = re.search(r"(?:\?|&)article=(\d+)", value) + if match: + return int(match.group(1)) + return None + + +def is_unrelated_image_candidate(image_url: str | None, image_credit: str | None) -> bool: + text = f"{image_url or ''} {image_credit or ''}".lower() + blocked = ( + "cat", + "dog", + "pet", + "animal", + "wildlife", + "lion", + "tiger", + "bird", + "horse", + ) + return any(term in text for term in blocked) + + +async def refetch_image_for_item( + item: NewsItem, + max_attempts: int, +) -> tuple[str | None, str | None, str]: + query = build_contextual_query(item.headline, item.summary) + current_summary_image = item.summary_image_url + query_variants = [ + f"{query} alternative angle", + f"{query} concept illustration", + query, + ] + + for query_variant in query_variants: + for attempt in range(max_attempts): + try: + image_url, image_credit = await fetch_royalty_free_image(query_variant) + if not image_url: + raise RuntimeError("no-image-url") + if is_unrelated_image_candidate(image_url, image_credit): + logger.info("Rejected unrelated image candidate: %s", image_url) + continue + local_image = await download_and_optimize_image(image_url) + if not local_image: + raise RuntimeError("image-download-or-optimize-failed") + if current_summary_image and local_image == current_summary_image: + logger.info("Rejected duplicate image candidate for article=%s", item.id) + continue + return local_image, image_credit, "provider" + except Exception: + if attempt < max_attempts - 1: + delay = 2**attempt + await asyncio.sleep(delay) + + fallback_local = await download_and_optimize_image(GENERIC_AI_FALLBACK_URL) + if fallback_local and fallback_local != current_summary_image: + return fallback_local, "AI-themed fallback", "fallback" + return None, None, "none" + + async def refetch_images_for_latest( limit: int, max_attempts: int, dry_run: bool, + target_article_id: int | None = None, ) -> tuple[int, int]: db = SessionLocal() processed = 0 refreshed = 0 try: - items = ( - db.query(NewsItem) - .filter(NewsItem.archived.is_(False)) - .order_by(desc(NewsItem.published_at)) - .limit(limit) - .all() - ) + if target_article_id is not None: + items = ( + db.query(NewsItem) + .filter(NewsItem.archived.is_(False), NewsItem.id == target_article_id) + .all() + ) + else: + items = ( + db.query(NewsItem) + .filter(NewsItem.archived.is_(False)) + .order_by(desc(NewsItem.published_at)) + .limit(limit) + .all() + ) total = len(items) for idx, item in enumerate(items, start=1): processed += 1 - query = build_contextual_query(item.headline, item.summary) - - image_url: str | None = None - image_credit: str | None = None - local_image: str | None = None - - for attempt in range(max_attempts): - try: - image_url, image_credit = await fetch_royalty_free_image(query) - if not image_url: - raise RuntimeError("no-image-url") - local_image = await download_and_optimize_image(image_url) - if not local_image: - raise RuntimeError("image-download-or-optimize-failed") - break - except Exception: - if attempt == max_attempts - 1: - logger.exception("Image refetch failed for item=%s after retries", item.id) - image_url = None - local_image = None - break - delay = 2**attempt - logger.warning( - "Refetch retry item=%s attempt=%d delay=%ds", - item.id, - attempt + 1, - delay, - ) - await asyncio.sleep(delay) + local_image, image_credit, decision = await refetch_image_for_item( + item=item, + max_attempts=max_attempts, + ) if local_image: refreshed += 1 @@ -152,6 +202,7 @@ async def refetch_images_for_latest( total=total, refreshed=refreshed, article_id=item.id, + decision=decision, ) return processed, refreshed @@ -186,6 +237,12 @@ def build_parser() -> argparse.ArgumentParser: help="Refetch and optimize latest article images", ) refetch_parser.add_argument("--limit", type=positive_int, default=30) + refetch_parser.add_argument( + "--permalink", + type=str, + default="", + help="Target one article by permalink (for example '/?article=123' or '123')", + ) refetch_parser.add_argument("--max-attempts", type=positive_int, default=4) refetch_parser.add_argument("--dry-run", action="store_true") refetch_parser.set_defaults(handler=handle_admin_refetch_images) @@ -280,11 +337,22 @@ def handle_admin_refetch_images(args: argparse.Namespace) -> int: start = time.monotonic() try: init_db() + target_article_id = resolve_article_id_from_permalink(args.permalink) + if args.permalink and target_article_id is None: + print_result( + "refetch-images", + "blocked", + reason="invalid-permalink", + hint="use '/?article=' or raw numeric id", + ) + return 2 + processed, refreshed = asyncio.run( refetch_images_for_latest( limit=min(args.limit, 30), max_attempts=args.max_attempts, dry_run=args.dry_run, + target_article_id=target_article_id, ) ) elapsed = time.monotonic() - start @@ -293,6 +361,7 @@ def handle_admin_refetch_images(args: argparse.Namespace) -> int: "ok", processed=processed, refreshed=refreshed, + target_article_id=target_article_id, dry_run=args.dry_run, elapsed=f"{elapsed:.1f}s", ) diff --git a/backend/main.py b/backend/main.py index 06e3cbd..f87992a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,18 +37,18 @@ app = FastAPI(title="ClawFort News API", version="0.1.0") _ERROR_MESSAGES = { 404: [ - "Oh no! This page wandered off to train a tiny model.", - "Oh no! We looked everywhere, even in the latent space.", - "Oh no! The link took a creative detour.", - "Oh no! This route is currently off doing research.", - "Oh no! The page you asked for is not in this timeline.", + "This page wandered off to train a tiny model.", + "We looked everywhere, even in the latent space.", + "The link took a creative detour.", + "This route is currently off doing research.", + "The page you asked for is not in this timeline.", ], 500: [ - "Oh no! The server hit a logic knot and needs a quick reset.", - "Oh no! Our robots dropped a semicolon somewhere important.", - "Oh no! A background process got stage fright.", - "Oh no! The AI took an unexpected coffee break.", - "Oh no! Something internal blinked at the wrong moment.", + "The server hit a logic knot and needs a quick reset.", + "Our robots dropped a semicolon somewhere important.", + "A background process got stage fright.", + "The AI took an unexpected coffee break.", + "Something internal blinked at the wrong moment.", ], } diff --git a/backend/news_service.py b/backend/news_service.py index 64bdd96..0391e30 100644 --- a/backend/news_service.py +++ b/backend/news_service.py @@ -25,6 +25,49 @@ logger = logging.getLogger(__name__) PLACEHOLDER_IMAGE_PATH = "/static/images/placeholder.png" GENERIC_AI_FALLBACK_URL = "https://placehold.co/1200x630/0f172a/e2e8f0/png?text=AI+News" +GENERIC_FINANCE_FALLBACK_URL = "https://placehold.co/1200x630/0f172a/e2e8f0/png?text=Market+News" + +_FINANCE_TOPIC_TERMS = frozenset( + { + "finance", + "financial", + "market", + "markets", + "stock", + "stocks", + "share", + "shares", + "earnings", + "investor", + "investors", + "nasdaq", + "nyse", + "dow", + "s&p", + "bank", + "banking", + "revenue", + "profit", + "trading", + "ipo", + "valuation", + } +) + +_FINANCE_IMAGE_BLOCKLIST = ( + "cat", + "dog", + "pet", + "lion", + "tiger", + "bird", + "horse", + "portrait", + "selfie", + "wedding", + "food", + "nature-only", +) async def call_perplexity_api(query: str) -> dict | None: @@ -174,6 +217,43 @@ def parse_translation_response(response: dict) -> dict | None: return None +def validate_translation_quality( + headline: str, summary: str, language_code: str +) -> tuple[bool, str | None]: + text = f"{headline} {summary}".strip() + if not headline or not summary: + return False, "empty-content" + if len(text) < 20: + return False, "too-short" + + repeated_runs = re.search(r"(.)\1{6,}", text) + if repeated_runs: + return False, "repeated-sequence" + + lines = [segment.strip() for segment in re.split(r"[.!?]\s+", text) if segment.strip()] + if lines: + unique_ratio = len(set(lines)) / len(lines) + if unique_ratio < 0.4: + return False, "low-unique-content" + + if language_code == "ta": + script_hits = sum(1 for char in text if "\u0b80" <= char <= "\u0bff") + elif language_code == "ml": + script_hits = sum(1 for char in text if "\u0d00" <= char <= "\u0d7f") + else: + return True, None + + alpha_hits = sum(1 for char in text if char.isalpha()) + if alpha_hits == 0: + return False, "no-alpha-content" + + script_ratio = script_hits / alpha_hits + if script_ratio < 0.35: + return False, "script-mismatch" + + return True, None + + async def generate_translations( headline: str, summary: str, @@ -200,7 +280,20 @@ async def generate_translations( if response: parsed = parse_translation_response(response) if parsed: - translations[language_code] = parsed + is_valid, reason = validate_translation_quality( + parsed["headline"], + parsed["summary"], + language_code, + ) + if is_valid: + logger.info("Translation accepted for %s", language_code) + translations[language_code] = parsed + else: + logger.warning( + "Translation rejected for %s: %s", + language_code, + reason, + ) except Exception: logger.exception("Translation generation failed for %s", language_code) @@ -467,7 +560,7 @@ async def fetch_pixabay_image(query: str) -> tuple[str | None, str | None]: except Exception: logger.exception("Pixabay image retrieval failed") - return GENERIC_AI_FALLBACK_URL, "Generic AI fallback" + return None, None async def fetch_unsplash_image(query: str) -> tuple[str | None, str | None]: @@ -591,6 +684,15 @@ def get_enabled_providers() -> list[ async def fetch_royalty_free_image(query: str) -> tuple[str | None, str | None]: """Fetch royalty-free image using provider chain with fallback.""" + + def is_finance_story(text: str) -> bool: + lowered = (text or "").lower() + return any(term in lowered for term in _FINANCE_TOPIC_TERMS) + + def is_finance_safe_image(image_url: str, credit: str | None) -> bool: + haystack = f"{image_url or ''} {credit or ''}".lower() + return not any(term in haystack for term in _FINANCE_IMAGE_BLOCKLIST) + # MCP endpoint takes highest priority if configured if config.ROYALTY_IMAGE_MCP_ENDPOINT: try: @@ -610,15 +712,35 @@ async def fetch_royalty_free_image(query: str) -> tuple[str | None, str | None]: # Extract keywords for better image search refined_query = extract_image_keywords(query) + finance_story = is_finance_story(query) + query_variants = [refined_query] + if finance_story: + query_variants = [ + f"{refined_query} stock market trading chart finance business", + refined_query, + ] # Try each enabled provider in order - for provider_name, fetch_fn in get_enabled_providers(): - try: - image_url, credit = await fetch_fn(refined_query) - if image_url: + for query_variant in query_variants: + for provider_name, fetch_fn in get_enabled_providers(): + try: + image_url, credit = await fetch_fn(query_variant) + if not image_url: + continue + if finance_story and not is_finance_safe_image(image_url, credit): + logger.info( + "Rejected non-finance-safe image from %s for query '%s': %s", + provider_name, + query_variant, + image_url, + ) + continue return image_url, credit - except Exception: - logger.exception("%s image retrieval failed", provider_name.capitalize()) + except Exception: + logger.exception("%s image retrieval failed", provider_name.capitalize()) + + if finance_story: + return GENERIC_FINANCE_FALLBACK_URL, "Finance-safe fallback" return None, None diff --git a/docs/monitoring-dashboard-config.md b/docs/monitoring-dashboard-config.md new file mode 100644 index 0000000..f1aa2d9 --- /dev/null +++ b/docs/monitoring-dashboard-config.md @@ -0,0 +1,26 @@ +# Monitoring Dashboard Configuration + +## Objective + +Define baseline dashboards and alert thresholds for reliability and freshness checks. + +## Dashboard Panels + +1. API p95 latency for `/api/news` and `/api/news/latest` +2. API error rate (`5xx`) by route +3. Scheduler success/failure count per hour +4. Feed freshness lag (minutes since latest published item) + +## Alert Thresholds + +- API latency alert: p95 > 750 ms for 10 minutes +- API error-rate alert: `5xx` > 3% for 5 minutes +- Scheduler alert: 2 consecutive failed fetch cycles +- Freshness alert: latest item older than 120 minutes + +## Test Trigger Plan + +- Latency trigger: run stress test against `/api/news` with 50 concurrent requests in staging. +- Error-rate trigger: simulate upstream timeout and confirm 5xx alert path. +- Scheduler trigger: disable upstream API key in staging and verify consecutive failure alert. +- Freshness trigger: pause scheduler for >120 minutes in staging and confirm lag alert. diff --git a/docs/p15-code-review-findings.md b/docs/p15-code-review-findings.md new file mode 100644 index 0000000..022bf8d --- /dev/null +++ b/docs/p15-code-review-findings.md @@ -0,0 +1,23 @@ +# P15 Code Review Findings + +Date: 2026-02-13 + +## High + +- owner=backend area=translations finding=Machine translation output is accepted without strict language validation in runtime flow, allowing occasional script mismatch/gibberish. + +## Medium + +- owner=frontend area=policy-disclosures finding=Terms and Attribution links previously required route navigation, reducing continuity and causing context loss. +- owner=backend area=admin-cli finding=Image refetch previously lacked permalink-targeted repair mode, forcing broad batch operations. + +## Low + +- owner=frontend area=sharing finding=Text-based icon actions in compact surfaces reduced visual consistency on small screens. + +## Remediation Status + +- translations-quality-gate: fixed-in-progress +- policy-modal-surface: fixed-in-progress +- permalink-targeted-refetch: fixed-in-progress +- icon-consistency: fixed-in-progress diff --git a/docs/quality-and-monitoring.md b/docs/quality-and-monitoring.md new file mode 100644 index 0000000..1e44e33 --- /dev/null +++ b/docs/quality-and-monitoring.md @@ -0,0 +1,64 @@ +# Quality and Monitoring Baseline + +## CI Quality Gates + +Pipeline file: `.github/workflows/quality-gates.yml` + +Stages: +- `lint-and-test`: Ruff + pytest (coverage threshold enforced). +- `security-scan`: `pip-audit` dependency vulnerability scan. + +Failure policy: +- Any failed stage blocks merge. +- Coverage floor below threshold blocks merge. + +## Coverage and Test Scope + +Current baseline suites: +- API contracts: `tests/test_api_contracts.py` +- DB lifecycle workflows: `tests/test_db_workflows.py` +- Accessibility contracts: `tests/test_accessibility_contract.py` +- Security/performance smoke checks: `tests/test_security_and_performance.py` + +## UX Validation Checklist + +Run manually on desktop + mobile viewport: +1. Hero loads with image and CTA visible. +2. Feed cards render with source and TL;DR CTA. +3. Modal opens/closes with Escape and backdrop click. +4. Share controls are visible in light and dark themes. +5. Floating back-to-top appears after scrolling and returns to top. + +## Production Metrics and Alert Thresholds + +| Metric | Target | Alert Threshold | +|---|---|---| +| API p95 latency (`/api/news`) | < 350 ms | > 750 ms for 10 min | +| API error rate (`5xx`) | < 1% | > 3% for 5 min | +| Scheduler success rate | 100% hourly runs | 2 consecutive failures | +| Feed freshness lag | < 75 min | > 120 min | + +## Alert Runbook + +### Incident: Elevated API latency +1. Confirm DB file I/O and host CPU saturation. +2. Inspect recent release diff for expensive queries. +3. Roll back latest deploy if regression is confirmed. + +### Incident: Scheduler failures +1. Check API key and upstream provider status. +2. Run `python -m backend.cli force-fetch` for repro. +3. Review logs for provider fallback exhaustion. + +### Incident: Error-rate spike +1. Check `/api/health` response and DB availability. +2. Identify top failing routes and common status codes. +3. Mitigate with rollback or feature flag disablement. + +## Review/Remediation Log Template + +Use this structure for each cycle: + +```text +severity= owner= area= finding= status= +``` diff --git a/frontend/index.html b/frontend/index.html index a1b6400..80f93ed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -103,8 +103,13 @@ --cf-select-text: #ffffff; --cf-select-border: rgba(255, 255, 255, 0.55); } - .cf-body { background: var(--cf-bg); color: var(--cf-text); } + .cf-body { background: var(--cf-bg); color: var(--cf-text); padding-bottom: 78px; } .cf-header { background: var(--cf-header-bg); } + .cf-header-top { box-shadow: none; border-color: rgba(148, 163, 184, 0.12); } + .cf-header-scrolled { + box-shadow: 0 10px 28px rgba(2, 6, 23, 0.24); + border-color: rgba(148, 163, 184, 0.28); + } .cf-card { background: var(--cf-card-bg) !important; } .cf-modal { background: var(--cf-modal-bg); } .cf-select { @@ -129,7 +134,7 @@ } .theme-menu-item:hover { background: rgba(92, 124, 250, 0.15); } .hero-overlay { - background: linear-gradient(to top, rgba(2, 6, 23, 0.94), rgba(15, 23, 42, 0.62), rgba(15, 23, 42, 0.22), transparent); + background: linear-gradient(to top, rgba(2, 6, 23, 0.97), rgba(15, 23, 42, 0.75), rgba(15, 23, 42, 0.35), transparent); } .hero-title { color: #e2e8f0; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55); } .hero-summary { color: #cbd5e1; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); } @@ -221,6 +226,14 @@ line-height: 1.78; letter-spacing: 0.01em; } + html[data-lang='ta'] .hero-title, + html[data-lang='ml'] .hero-title { + text-shadow: 0 3px 12px rgba(0, 0, 0, 0.75); + } + html[data-lang='ta'] .hero-summary, + html[data-lang='ml'] .hero-summary { + text-shadow: 0 2px 10px rgba(0, 0, 0, 0.7); + } .share-icon-btn { width: 34px; height: 34px; @@ -234,8 +247,42 @@ transition: background 180ms ease; } .share-icon-btn:hover { background: rgba(92, 124, 250, 0.25); } + html[data-theme='light'] .share-icon-btn { + color: #1d4ed8; + border-color: rgba(37, 99, 235, 0.45); + background: rgba(59, 130, 246, 0.12); + } + html[data-theme='light'] .share-icon-btn:hover { + background: rgba(59, 130, 246, 0.2); + } .footer-link { text-decoration: underline; text-underline-offset: 2px; } .footer-link:hover { color: #dbeafe; } + .site-footer { + background: color-mix(in srgb, var(--cf-bg) 92%, transparent); + backdrop-filter: blur(10px); + } + .back-to-top-island { + position: fixed; + right: 14px; + bottom: 88px; + z-index: 55; + border-radius: 9999px; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.88); + color: #dbeafe; + min-width: 44px; + min-height: 44px; + padding: 0 13px; + font-size: 12px; + font-weight: 700; + line-height: 1; + box-shadow: 0 12px 24px rgba(2, 6, 23, 0.35); + } + html[data-theme='light'] .back-to-top-island { + background: rgba(248, 250, 252, 0.96); + color: #1e3a8a; + border-color: rgba(37, 99, 235, 0.35); + } .contact-hint { position: fixed; z-index: 60; @@ -256,14 +303,31 @@ } @media (max-width: 640px) { .theme-btn { width: 26px; height: 26px; } + .cf-body { padding-bottom: 84px; } + html[data-lang='ta'] .hero-title, + html[data-lang='ml'] .hero-title { + font-size: 1.9rem; + line-height: 1.26; + letter-spacing: 0.01em; + } + html[data-lang='ta'] .hero-summary, + html[data-lang='ml'] .hero-summary { + font-size: 1.08rem; + line-height: 1.86; + letter-spacing: 0.015em; + } + .back-to-top-island { + right: 10px; + bottom: 82px; + } } Skip to content -
- @@ -409,10 +470,7 @@ @click.stop="trackEvent('source-link-click')" x-show="item.source_url" x-text="extractDomain(item.source_url)"> -
- Link - -
+ +

Permalink copied.

@@ -508,16 +572,54 @@
-