Spaces:
Sleeping
feat: Harden API, pin dependencies, and enable pytest CI gate
Browse files- /health/deep: 1-token ping per configured LLM provider (key presence
no longer masquerades as a working model)
- Abuse guards on POST /analyze: per-IP rate limit (10/h), concurrent
workflow cap (3), and 1h TTL eviction for the in-memory store
- CORS: hf.space subdomains via allow_origin_regex (literal wildcard was
a no-op); drop unused allow_credentials
- Lifespan handler replaces deprecated @app .on_event startup hook
- Cache-Control: immutable for hashed /assets, no-cache for the shell
- Dependencies: requirements.in + uv-compiled fully pinned requirements.txt
(captures the exact resolution validated by today's live canary);
Dockerfile uses npm ci for the frontend
- Tests: replace live-integration test (needed API keys, imported deleted
graph_cyclic) with unit tests for loop routing; pytest added to CI
- .github/workflows/ci.yml +3 -1
- Dockerfile +2 -2
- docs/audit_checklist.md +19 -19
- requirements.in +30 -0
- requirements.txt +196 -30
- src/api/app.py +65 -10
- src/api/routes/analysis.py +52 -2
- tests/test_conditions.py +41 -0
- tests/test_self_correcting_loop.py +0 -79
|
@@ -16,9 +16,11 @@ jobs:
|
|
| 16 |
python-version: "3.11"
|
| 17 |
cache: pip
|
| 18 |
- name: Install dependencies
|
| 19 |
-
run: pip install -r requirements.txt flake8
|
| 20 |
- name: Lint (critical errors only)
|
| 21 |
run: flake8 src --select=E9,F63,F7,F82 --show-source
|
|
|
|
|
|
|
| 22 |
- name: Import smoke test
|
| 23 |
run: python -c "from src.api.app import app; from src.workflow.graph import app as graph_app; print('imports OK')"
|
| 24 |
|
|
|
|
| 16 |
python-version: "3.11"
|
| 17 |
cache: pip
|
| 18 |
- name: Install dependencies
|
| 19 |
+
run: pip install -r requirements.txt flake8 pytest
|
| 20 |
- name: Lint (critical errors only)
|
| 21 |
run: flake8 src --select=E9,F63,F7,F82 --show-source
|
| 22 |
+
- name: Unit tests
|
| 23 |
+
run: python -m pytest tests -q
|
| 24 |
- name: Import smoke test
|
| 25 |
run: python -c "from src.api.app import app; from src.workflow.graph import app as graph_app; print('imports OK')"
|
| 26 |
|
|
@@ -9,8 +9,8 @@ WORKDIR /frontend
|
|
| 9 |
# Copy package files first for better caching
|
| 10 |
COPY frontend/package.json frontend/package-lock.json ./
|
| 11 |
|
| 12 |
-
# Install dependencies
|
| 13 |
-
RUN npm
|
| 14 |
|
| 15 |
# Copy frontend source code
|
| 16 |
COPY frontend/ ./
|
|
|
|
| 9 |
# Copy package files first for better caching
|
| 10 |
COPY frontend/package.json frontend/package-lock.json ./
|
| 11 |
|
| 12 |
+
# Install dependencies from the lockfile for reproducible builds
|
| 13 |
+
RUN npm ci --legacy-peer-deps
|
| 14 |
|
| 15 |
# Copy frontend source code
|
| 16 |
COPY frontend/ ./
|
|
@@ -5,42 +5,42 @@
|
|
| 5 |
- [x] Remove `frontend/.env.production` (deleted from working tree 2026-07-17; commit pending)
|
| 6 |
- [x] Commit the `BUSINESS_README.md` change (95a89bb, pushed 2026-07-18)
|
| 7 |
- [x] Push `origin/main` to the `hf` remote β drift closed, both remotes at 7045b2d (2026-07-18)
|
| 8 |
-
- [
|
| 9 |
- [x] Update code defaults in `src/llm_client.py` and `.env.example` to the new model IDs (done 2026-07-17; commit pending)
|
| 10 |
- [x] Fix `A2A_RESEARCHER_URL` in `.env.example` (done 2026-07-17; commit pending)
|
| 11 |
- [x] Replace SEC EDGAR User-Agent placeholder with real contact (fetchers.py + config.py, pushed 2026-07-18)
|
| 12 |
- [x] Add a LICENSE file (MIT) to Instant-SWOT-Agent (pushed 2026-07-18)
|
| 13 |
- [x] Add a LICENSE file (MIT) to Researcher-Agent (pushed 2026-07-18)
|
| 14 |
-
- [
|
| 15 |
-
- [
|
| 16 |
-
- [
|
| 17 |
-
- [
|
| 18 |
- [x] Add GitHub Actions CI (2026-07-18): flake8 critical + import smoke on backend, eslint (non-blocking) + build on frontend; pytest excluded β existing tests are live integration tests needing API keys, modernize under the test-suite item
|
| 19 |
- [x] Add GitHub Actions auto-mirror `main` -> HF Space via `HF_TOKEN` secret (2026-07-18)
|
| 20 |
- [x] Investigate the 0-revision anomaly: resolved β with working models the loop revises correctly (MSFT run: 3 revisions, score 5.7); earlier 0-revision AAPL run was likely `analyzer_revision_skipped` triggered by dead fallback models
|
| 21 |
- [ ] Enable LangSmith tracing (`LANGCHAIN_TRACING_V2=true` + key on the HF Space) to support the loop investigation
|
| 22 |
-
- [
|
| 23 |
- [x] Check the Supabase project is not paused (confirmed working 2026-07-17: https://atilxlecbaqcksnrgzav.supabase.co)
|
| 24 |
-
- [
|
| 25 |
- [x] Add missing env vars to Researcher-Agent `.env.example` (pushed 2026-07-18)
|
| 26 |
- [ ] Confirm which optional keys (NYT, NewsAPI, Alpha Vantage, Tradier, BLS, BEA) are actually set as Researcher-Agent Space secrets and prune unused ones
|
| 27 |
- [x] Decide whether AWS or HF Spaces is the canonical backend (decided: HF; AWS backend torn down 2026-07-17 via AWS CLI β API Gateway, 8 Lambdas, Step Functions, 2 DynamoDB tables, 3 S3 buckets, 2 alarms, 8 log groups, 2 IAM roles deleted; secret `swot-agent-api-keys` scheduled for deletion 2026-07-24)
|
| 28 |
-
- [
|
| 29 |
-
- [
|
| 30 |
-
- [
|
| 31 |
-
- [
|
| 32 |
-
- [
|
| 33 |
-
- [
|
| 34 |
-
- [
|
| 35 |
-
- [
|
| 36 |
- [x] Remove repo noise from Researcher-Agent: `reports/` and `.obsidian/` untracked + gitignored (pushed 2026-07-18)
|
| 37 |
-
- [
|
| 38 |
- [ ] Switch to structured JSON outputs (`response_format: json_object`) for critic/analyzer calls on Groq/OpenRouter
|
| 39 |
-
- [
|
| 40 |
-
- [
|
| 41 |
- [ ] Keep MCP server sessions persistent in Researcher-Agent instead of spawning 6 subprocesses per request
|
| 42 |
- [x] Add scheduled live canary: weekly (Mon 06:17 UTC) E2E analysis asserting provider, SWOT sections, and data sources (2026-07-18)
|
| 43 |
- [x] Weekly keep-warm ping: covered by the canary's health checks against both Spaces (2026-07-18)
|
| 44 |
- [ ] Upgrade Vite 5 β 7 (EOL) and React 18 β 19 when next touching the frontend
|
| 45 |
-
- [
|
| 46 |
- [ ] Consider SSE streaming of workflow status to replace 15s frontend polling
|
|
|
|
| 5 |
- [x] Remove `frontend/.env.production` (deleted from working tree 2026-07-17; commit pending)
|
| 6 |
- [x] Commit the `BUSINESS_README.md` change (95a89bb, pushed 2026-07-18)
|
| 7 |
- [x] Push `origin/main` to the `hf` remote β drift closed, both remotes at 7045b2d (2026-07-18)
|
| 8 |
+
- [x] After redeploy, served JS bundle verified free of `execute-api` URLs (2026-07-18)
|
| 9 |
- [x] Update code defaults in `src/llm_client.py` and `.env.example` to the new model IDs (done 2026-07-17; commit pending)
|
| 10 |
- [x] Fix `A2A_RESEARCHER_URL` in `.env.example` (done 2026-07-17; commit pending)
|
| 11 |
- [x] Replace SEC EDGAR User-Agent placeholder with real contact (fetchers.py + config.py, pushed 2026-07-18)
|
| 12 |
- [x] Add a LICENSE file (MIT) to Instant-SWOT-Agent (pushed 2026-07-18)
|
| 13 |
- [x] Add a LICENSE file (MIT) to Researcher-Agent (pushed 2026-07-18)
|
| 14 |
+
- [x] Pin Python dependencies via uv pip compile (requirements.in -> fully pinned requirements.txt, 2026-07-18)
|
| 15 |
+
- [x] Pin Researcher-Agent root requirements via uv (2026-07-18); per-MCP-server requirements files are for standalone use only (Docker installs root file)
|
| 16 |
+
- [x] Dockerfile frontend stage now uses `npm ci --legacy-peer-deps` (2026-07-18)
|
| 17 |
+
- [x] Pinned resolution (langgraph 0.6.11, langchain 0.3.30) matches the rebuild the live canary validated on 2026-07-18
|
| 18 |
- [x] Add GitHub Actions CI (2026-07-18): flake8 critical + import smoke on backend, eslint (non-blocking) + build on frontend; pytest excluded β existing tests are live integration tests needing API keys, modernize under the test-suite item
|
| 19 |
- [x] Add GitHub Actions auto-mirror `main` -> HF Space via `HF_TOKEN` secret (2026-07-18)
|
| 20 |
- [x] Investigate the 0-revision anomaly: resolved β with working models the loop revises correctly (MSFT run: 3 revisions, score 5.7); earlier 0-revision AAPL run was likely `analyzer_revision_skipped` triggered by dead fallback models
|
| 21 |
- [ ] Enable LangSmith tracing (`LANGCHAIN_TRACING_V2=true` + key on the HF Space) to support the loop investigation
|
| 22 |
+
- [x] Add `/health/deep` endpoint pinging each configured LLM with a tiny request (2026-07-18)
|
| 23 |
- [x] Check the Supabase project is not paused (confirmed working 2026-07-17: https://atilxlecbaqcksnrgzav.supabase.co)
|
| 24 |
+
- [x] Document `PIPELINE_SUPABASE_URL` in `.env.example` and CLAUDE.md (2026-07-18)
|
| 25 |
- [x] Add missing env vars to Researcher-Agent `.env.example` (pushed 2026-07-18)
|
| 26 |
- [ ] Confirm which optional keys (NYT, NewsAPI, Alpha Vantage, Tradier, BLS, BEA) are actually set as Researcher-Agent Space secrets and prune unused ones
|
| 27 |
- [x] Decide whether AWS or HF Spaces is the canonical backend (decided: HF; AWS backend torn down 2026-07-17 via AWS CLI β API Gateway, 8 Lambdas, Step Functions, 2 DynamoDB tables, 3 S3 buckets, 2 alarms, 8 log groups, 2 IAM roles deleted; secret `swot-agent-api-keys` scheduled for deletion 2026-07-24)
|
| 28 |
+
- [x] Remove `awsDeployment/` directory (2026-07-18; history preserves it)
|
| 29 |
+
- [x] Fix CORS: `allow_origin_regex` for hf.space subdomains, credentials dropped (2026-07-18)
|
| 30 |
+
- [x] Add per-IP rate limit (10/h) and concurrent-workflow cap (3) on `POST /analyze` (2026-07-18, dependency-free in-memory guards)
|
| 31 |
+
- [x] Add 1h TTL eviction for `WORKFLOWS` (analysis route) and Researcher `TASK_STORE` (2026-07-18)
|
| 32 |
+
- [x] Update README.md and CLAUDE.md to match the real workflow (2026-07-18)
|
| 33 |
+
- [x] Delete dead code: `src/graph_cyclic.py` (2026-07-18)
|
| 34 |
+
- [x] Remove unused deps from `requirements.txt` (2026-07-18)
|
| 35 |
+
- [x] Rewrite stale `pyproject.toml` (2026-07-18)
|
| 36 |
- [x] Remove repo noise from Researcher-Agent: `reports/` and `.obsidian/` untracked + gitignored (pushed 2026-07-18)
|
| 37 |
+
- [x] Delete the local 265MB `awsDeployment/build/` directory (2026-07-18)
|
| 38 |
- [ ] Switch to structured JSON outputs (`response_format: json_object`) for critic/analyzer calls on Groq/OpenRouter
|
| 39 |
+
- [x] Replace deprecated `@app.on_event` with a lifespan handler (2026-07-18)
|
| 40 |
+
- [x] Test suite green: live-integration test replaced with 7 unit tests for the loop conditions; pytest now gates CI (2026-07-18)
|
| 41 |
- [ ] Keep MCP server sessions persistent in Researcher-Agent instead of spawning 6 subprocesses per request
|
| 42 |
- [x] Add scheduled live canary: weekly (Mon 06:17 UTC) E2E analysis asserting provider, SWOT sections, and data sources (2026-07-18)
|
| 43 |
- [x] Weekly keep-warm ping: covered by the canary's health checks against both Spaces (2026-07-18)
|
| 44 |
- [ ] Upgrade Vite 5 β 7 (EOL) and React 18 β 19 when next touching the frontend
|
| 45 |
+
- [x] Add cache headers: immutable for hashed /assets, no-cache for the app shell (2026-07-18)
|
| 46 |
- [ ] Consider SSE streaming of workflow status to replace 15s frontend polling
|
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core
|
| 2 |
+
python-dotenv>=1.0.0
|
| 3 |
+
pydantic>=2.0.0
|
| 4 |
+
|
| 5 |
+
# LangChain/LangGraph
|
| 6 |
+
langchain>=0.1.0,<1.0
|
| 7 |
+
langgraph>=0.1.0,<1.0
|
| 8 |
+
langchain-core>=0.1.0,<1.0
|
| 9 |
+
langsmith>=0.1.0
|
| 10 |
+
|
| 11 |
+
# LLM Providers (Groq is primary, others are fallback)
|
| 12 |
+
requests>=2.31.0
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# HTTP Client
|
| 16 |
+
httpx>=0.27.0
|
| 17 |
+
|
| 18 |
+
# Yahoo Finance
|
| 19 |
+
yfinance>=0.2.40
|
| 20 |
+
|
| 21 |
+
# A2A Server (for Researcher A2A mode)
|
| 22 |
+
fastapi>=0.115.0
|
| 23 |
+
uvicorn>=0.32.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Sentiment Analysis
|
| 27 |
+
vaderSentiment>=3.3.2
|
| 28 |
+
|
| 29 |
+
# Database
|
| 30 |
+
psycopg2-binary>=2.9.0
|
|
@@ -1,30 +1,196 @@
|
|
| 1 |
-
#
|
| 2 |
-
python-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
#
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
#
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv pip compile requirements.in -o requirements.txt --python-version 3.11
|
| 3 |
+
annotated-doc==0.0.4
|
| 4 |
+
# via fastapi
|
| 5 |
+
annotated-types==0.7.0
|
| 6 |
+
# via pydantic
|
| 7 |
+
anyio==4.14.2
|
| 8 |
+
# via
|
| 9 |
+
# httpx
|
| 10 |
+
# langsmith
|
| 11 |
+
# starlette
|
| 12 |
+
beautifulsoup4==4.15.0
|
| 13 |
+
# via yfinance
|
| 14 |
+
certifi==2026.6.17
|
| 15 |
+
# via
|
| 16 |
+
# curl-cffi
|
| 17 |
+
# httpcore
|
| 18 |
+
# httpx
|
| 19 |
+
# requests
|
| 20 |
+
cffi==2.1.0
|
| 21 |
+
# via curl-cffi
|
| 22 |
+
charset-normalizer==3.4.9
|
| 23 |
+
# via requests
|
| 24 |
+
click==8.4.2
|
| 25 |
+
# via uvicorn
|
| 26 |
+
curl-cffi==0.15.0
|
| 27 |
+
# via yfinance
|
| 28 |
+
distro==1.9.0
|
| 29 |
+
# via langsmith
|
| 30 |
+
fastapi==0.139.2
|
| 31 |
+
# via -r requirements.in
|
| 32 |
+
greenlet==3.5.3
|
| 33 |
+
# via sqlalchemy
|
| 34 |
+
h11==0.16.0
|
| 35 |
+
# via
|
| 36 |
+
# httpcore
|
| 37 |
+
# uvicorn
|
| 38 |
+
httpcore==1.0.9
|
| 39 |
+
# via httpx
|
| 40 |
+
httpx==0.28.1
|
| 41 |
+
# via
|
| 42 |
+
# -r requirements.in
|
| 43 |
+
# langgraph-sdk
|
| 44 |
+
# langsmith
|
| 45 |
+
idna==3.18
|
| 46 |
+
# via
|
| 47 |
+
# anyio
|
| 48 |
+
# httpx
|
| 49 |
+
# requests
|
| 50 |
+
jsonpatch==1.33
|
| 51 |
+
# via langchain-core
|
| 52 |
+
jsonpointer==3.1.1
|
| 53 |
+
# via jsonpatch
|
| 54 |
+
langchain==0.3.30
|
| 55 |
+
# via -r requirements.in
|
| 56 |
+
langchain-core==0.3.86
|
| 57 |
+
# via
|
| 58 |
+
# -r requirements.in
|
| 59 |
+
# langchain
|
| 60 |
+
# langchain-text-splitters
|
| 61 |
+
# langgraph
|
| 62 |
+
# langgraph-checkpoint
|
| 63 |
+
# langgraph-prebuilt
|
| 64 |
+
langchain-text-splitters==0.3.11
|
| 65 |
+
# via langchain
|
| 66 |
+
langgraph==0.6.11
|
| 67 |
+
# via -r requirements.in
|
| 68 |
+
langgraph-checkpoint==3.0.1
|
| 69 |
+
# via
|
| 70 |
+
# langgraph
|
| 71 |
+
# langgraph-prebuilt
|
| 72 |
+
langgraph-prebuilt==0.6.5
|
| 73 |
+
# via langgraph
|
| 74 |
+
langgraph-sdk==0.2.15
|
| 75 |
+
# via langgraph
|
| 76 |
+
langsmith==0.10.6
|
| 77 |
+
# via
|
| 78 |
+
# -r requirements.in
|
| 79 |
+
# langchain
|
| 80 |
+
# langchain-core
|
| 81 |
+
markdown-it-py==4.2.0
|
| 82 |
+
# via rich
|
| 83 |
+
mdurl==0.1.2
|
| 84 |
+
# via markdown-it-py
|
| 85 |
+
multitasking==0.0.13
|
| 86 |
+
# via yfinance
|
| 87 |
+
numpy==2.4.6
|
| 88 |
+
# via
|
| 89 |
+
# pandas
|
| 90 |
+
# yfinance
|
| 91 |
+
orjson==3.11.9
|
| 92 |
+
# via
|
| 93 |
+
# langgraph-sdk
|
| 94 |
+
# langsmith
|
| 95 |
+
ormsgpack==1.12.2
|
| 96 |
+
# via langgraph-checkpoint
|
| 97 |
+
packaging==25.0
|
| 98 |
+
# via
|
| 99 |
+
# langchain-core
|
| 100 |
+
# langsmith
|
| 101 |
+
pandas==3.0.3
|
| 102 |
+
# via yfinance
|
| 103 |
+
peewee==4.2.6
|
| 104 |
+
# via yfinance
|
| 105 |
+
platformdirs==4.10.0
|
| 106 |
+
# via yfinance
|
| 107 |
+
protobuf==7.35.1
|
| 108 |
+
# via yfinance
|
| 109 |
+
psycopg2-binary==2.9.12
|
| 110 |
+
# via -r requirements.in
|
| 111 |
+
pycparser==3.0
|
| 112 |
+
# via cffi
|
| 113 |
+
pydantic==2.13.4
|
| 114 |
+
# via
|
| 115 |
+
# -r requirements.in
|
| 116 |
+
# fastapi
|
| 117 |
+
# langchain
|
| 118 |
+
# langchain-core
|
| 119 |
+
# langgraph
|
| 120 |
+
# langsmith
|
| 121 |
+
pydantic-core==2.46.4
|
| 122 |
+
# via pydantic
|
| 123 |
+
pygments==2.20.0
|
| 124 |
+
# via rich
|
| 125 |
+
python-dateutil==2.9.0.post0
|
| 126 |
+
# via pandas
|
| 127 |
+
python-dotenv==1.2.2
|
| 128 |
+
# via -r requirements.in
|
| 129 |
+
pytz==2026.2
|
| 130 |
+
# via yfinance
|
| 131 |
+
pyyaml==6.0.3
|
| 132 |
+
# via
|
| 133 |
+
# langchain
|
| 134 |
+
# langchain-core
|
| 135 |
+
requests==2.34.2
|
| 136 |
+
# via
|
| 137 |
+
# -r requirements.in
|
| 138 |
+
# langchain
|
| 139 |
+
# langsmith
|
| 140 |
+
# requests-toolbelt
|
| 141 |
+
# vadersentiment
|
| 142 |
+
# yfinance
|
| 143 |
+
requests-toolbelt==1.0.0
|
| 144 |
+
# via langsmith
|
| 145 |
+
rich==15.0.0
|
| 146 |
+
# via curl-cffi
|
| 147 |
+
six==1.17.0
|
| 148 |
+
# via python-dateutil
|
| 149 |
+
sniffio==1.3.1
|
| 150 |
+
# via langsmith
|
| 151 |
+
soupsieve==2.8.4
|
| 152 |
+
# via beautifulsoup4
|
| 153 |
+
sqlalchemy==2.0.51
|
| 154 |
+
# via langchain
|
| 155 |
+
starlette==1.3.1
|
| 156 |
+
# via fastapi
|
| 157 |
+
tenacity==9.1.4
|
| 158 |
+
# via langchain-core
|
| 159 |
+
typing-extensions==4.16.0
|
| 160 |
+
# via
|
| 161 |
+
# anyio
|
| 162 |
+
# beautifulsoup4
|
| 163 |
+
# fastapi
|
| 164 |
+
# langchain-core
|
| 165 |
+
# langsmith
|
| 166 |
+
# pydantic
|
| 167 |
+
# pydantic-core
|
| 168 |
+
# sqlalchemy
|
| 169 |
+
# starlette
|
| 170 |
+
# typing-inspection
|
| 171 |
+
typing-inspection==0.4.2
|
| 172 |
+
# via
|
| 173 |
+
# fastapi
|
| 174 |
+
# pydantic
|
| 175 |
+
urllib3==2.7.0
|
| 176 |
+
# via requests
|
| 177 |
+
uuid-utils==0.17.0
|
| 178 |
+
# via
|
| 179 |
+
# langchain-core
|
| 180 |
+
# langsmith
|
| 181 |
+
uvicorn==0.51.0
|
| 182 |
+
# via -r requirements.in
|
| 183 |
+
vadersentiment==3.3.2
|
| 184 |
+
# via -r requirements.in
|
| 185 |
+
websockets==16.1.1
|
| 186 |
+
# via
|
| 187 |
+
# langsmith
|
| 188 |
+
# yfinance
|
| 189 |
+
xxhash==3.8.1
|
| 190 |
+
# via
|
| 191 |
+
# langgraph
|
| 192 |
+
# langsmith
|
| 193 |
+
yfinance==1.5.1
|
| 194 |
+
# via -r requirements.in
|
| 195 |
+
zstandard==0.25.0
|
| 196 |
+
# via langsmith
|
|
@@ -29,13 +29,26 @@ if os.getenv("OPENROUTER_API_KEY"):
|
|
| 29 |
_llm_providers.append("OpenRouter")
|
| 30 |
print(f"[Startup] LLM providers available: {_llm_providers or 'NONE - check HF Spaces secrets!'}")
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
app = FastAPI(
|
| 33 |
title="Instant SWOT Agent API",
|
| 34 |
description="Multi-agent SWOT analysis with self-correcting quality control",
|
| 35 |
-
version="2.0.0"
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
-
# CORS configuration for React frontend
|
|
|
|
|
|
|
| 39 |
app.add_middleware(
|
| 40 |
CORSMiddleware,
|
| 41 |
allow_origins=[
|
|
@@ -44,24 +57,28 @@ app.add_middleware(
|
|
| 44 |
"http://localhost:8081",
|
| 45 |
"http://localhost:3000",
|
| 46 |
"https://huggingface.co",
|
| 47 |
-
"https://*.hf.space",
|
| 48 |
],
|
| 49 |
-
|
| 50 |
allow_methods=["*"],
|
| 51 |
allow_headers=["*"],
|
| 52 |
)
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
# Include routers
|
| 55 |
app.include_router(analysis_router)
|
| 56 |
app.include_router(stocks_router)
|
| 57 |
|
| 58 |
|
| 59 |
-
@app.on_event("startup")
|
| 60 |
-
async def startup_event():
|
| 61 |
-
"""Load stock listings on startup."""
|
| 62 |
-
await load_stock_listings()
|
| 63 |
-
|
| 64 |
-
|
| 65 |
@app.get("/health")
|
| 66 |
async def health_check():
|
| 67 |
"""Health check endpoint."""
|
|
@@ -78,6 +95,44 @@ async def health_check():
|
|
| 78 |
}
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@app.get("/api")
|
| 82 |
async def api_info():
|
| 83 |
"""API info endpoint."""
|
|
|
|
| 29 |
_llm_providers.append("OpenRouter")
|
| 30 |
print(f"[Startup] LLM providers available: {_llm_providers or 'NONE - check HF Spaces secrets!'}")
|
| 31 |
|
| 32 |
+
from contextlib import asynccontextmanager
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@asynccontextmanager
|
| 36 |
+
async def lifespan(_app: FastAPI):
|
| 37 |
+
"""Load stock listings on startup."""
|
| 38 |
+
await load_stock_listings()
|
| 39 |
+
yield
|
| 40 |
+
|
| 41 |
+
|
| 42 |
app = FastAPI(
|
| 43 |
title="Instant SWOT Agent API",
|
| 44 |
description="Multi-agent SWOT analysis with self-correcting quality control",
|
| 45 |
+
version="2.0.0",
|
| 46 |
+
lifespan=lifespan,
|
| 47 |
)
|
| 48 |
|
| 49 |
+
# CORS configuration for React frontend.
|
| 50 |
+
# Note: literal wildcards like "https://*.hf.space" are NOT matched by
|
| 51 |
+
# allow_origins; subdomain patterns require allow_origin_regex.
|
| 52 |
app.add_middleware(
|
| 53 |
CORSMiddleware,
|
| 54 |
allow_origins=[
|
|
|
|
| 57 |
"http://localhost:8081",
|
| 58 |
"http://localhost:3000",
|
| 59 |
"https://huggingface.co",
|
|
|
|
| 60 |
],
|
| 61 |
+
allow_origin_regex=r"https://.*\.hf\.space",
|
| 62 |
allow_methods=["*"],
|
| 63 |
allow_headers=["*"],
|
| 64 |
)
|
| 65 |
|
| 66 |
+
|
| 67 |
+
@app.middleware("http")
|
| 68 |
+
async def cache_headers(request, call_next):
|
| 69 |
+
"""Immutable caching for hashed /assets bundles; no-cache for the shell."""
|
| 70 |
+
response = await call_next(request)
|
| 71 |
+
if request.url.path.startswith("/assets/"):
|
| 72 |
+
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
| 73 |
+
elif request.url.path == "/":
|
| 74 |
+
response.headers["Cache-Control"] = "no-cache"
|
| 75 |
+
return response
|
| 76 |
+
|
| 77 |
# Include routers
|
| 78 |
app.include_router(analysis_router)
|
| 79 |
app.include_router(stocks_router)
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
@app.get("/health")
|
| 83 |
async def health_check():
|
| 84 |
"""Health check endpoint."""
|
|
|
|
| 95 |
}
|
| 96 |
|
| 97 |
|
| 98 |
+
@app.get("/health/deep")
|
| 99 |
+
def health_check_deep():
|
| 100 |
+
"""
|
| 101 |
+
Deep health check: sends a 1-token prompt to every configured LLM provider.
|
| 102 |
+
Distinguishes 'key present' from 'model actually serves' β a plain key
|
| 103 |
+
check masked the June 2026 Gemini/OpenRouter model retirements.
|
| 104 |
+
Runs in the threadpool (sync def) since provider calls are blocking.
|
| 105 |
+
"""
|
| 106 |
+
from src.llm_client import LLMClient
|
| 107 |
+
|
| 108 |
+
results = {}
|
| 109 |
+
try:
|
| 110 |
+
client = LLMClient()
|
| 111 |
+
except ValueError as e:
|
| 112 |
+
return {"status": "error", "detail": str(e), "providers": {}}
|
| 113 |
+
|
| 114 |
+
for provider in client.providers:
|
| 115 |
+
try:
|
| 116 |
+
content, error = client._call_provider(
|
| 117 |
+
provider=provider, prompt="Reply with: OK",
|
| 118 |
+
temperature=0, max_tokens=20
|
| 119 |
+
)
|
| 120 |
+
results[provider["name"]] = {
|
| 121 |
+
"model": provider["model"],
|
| 122 |
+
"ok": bool(content),
|
| 123 |
+
"error": error,
|
| 124 |
+
}
|
| 125 |
+
except Exception as e:
|
| 126 |
+
results[provider["name"]] = {
|
| 127 |
+
"model": provider["model"], "ok": False, "error": str(e)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"status": "ok" if any(r["ok"] for r in results.values()) else "degraded",
|
| 132 |
+
"providers": results,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
@app.get("/api")
|
| 137 |
async def api_info():
|
| 138 |
"""API info endpoint."""
|
|
@@ -3,10 +3,12 @@ Analysis and workflow route handlers.
|
|
| 3 |
Handles SWOT analysis workflow lifecycle.
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import uuid
|
| 7 |
import threading
|
|
|
|
| 8 |
|
| 9 |
-
from fastapi import APIRouter, HTTPException
|
| 10 |
|
| 11 |
from src.api.schemas import AnalysisRequest, WorkflowStartResponse
|
| 12 |
from src.services.workflow_store import (
|
|
@@ -18,14 +20,62 @@ from src.services.workflow_store import (
|
|
| 18 |
|
| 19 |
router = APIRouter()
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
@router.post("/analyze", response_model=WorkflowStartResponse)
|
| 23 |
-
async def start_analysis(request: AnalysisRequest):
|
| 24 |
"""Start a new SWOT analysis workflow."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
workflow_id = str(uuid.uuid4())
|
| 26 |
|
| 27 |
# Initialize workflow state
|
| 28 |
WORKFLOWS[workflow_id] = {
|
|
|
|
| 29 |
"status": "starting",
|
| 30 |
"current_step": "input",
|
| 31 |
"revision_count": 0,
|
|
|
|
| 3 |
Handles SWOT analysis workflow lifecycle.
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import time
|
| 7 |
import uuid
|
| 8 |
import threading
|
| 9 |
+
from collections import defaultdict, deque
|
| 10 |
|
| 11 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 12 |
|
| 13 |
from src.api.schemas import AnalysisRequest, WorkflowStartResponse
|
| 14 |
from src.services.workflow_store import (
|
|
|
|
| 20 |
|
| 21 |
router = APIRouter()
|
| 22 |
|
| 23 |
+
# Abuse guards for the public endpoint (in-memory, per-process)
|
| 24 |
+
WORKFLOW_TTL_SECONDS = 3600 # evict finished workflows after 1h
|
| 25 |
+
MAX_ACTIVE_WORKFLOWS = 3 # concurrent analyses per instance
|
| 26 |
+
RATE_LIMIT_WINDOW_SECONDS = 3600
|
| 27 |
+
RATE_LIMIT_MAX_REQUESTS = 10 # analyses per IP per window
|
| 28 |
+
_REQUESTS_BY_IP: dict = defaultdict(deque)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _evict_stale_workflows():
|
| 32 |
+
"""Drop workflows past TTL so the in-memory store cannot grow unbounded."""
|
| 33 |
+
cutoff = time.time() - WORKFLOW_TTL_SECONDS
|
| 34 |
+
for wid in [
|
| 35 |
+
wid for wid, wf in WORKFLOWS.items()
|
| 36 |
+
if wf.get("created_at", 0) < cutoff
|
| 37 |
+
and wf.get("status") not in ("starting", "running")
|
| 38 |
+
]:
|
| 39 |
+
WORKFLOWS.pop(wid, None)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _check_rate_limit(client_ip: str):
|
| 43 |
+
now = time.time()
|
| 44 |
+
window = _REQUESTS_BY_IP[client_ip]
|
| 45 |
+
while window and window[0] < now - RATE_LIMIT_WINDOW_SECONDS:
|
| 46 |
+
window.popleft()
|
| 47 |
+
if len(window) >= RATE_LIMIT_MAX_REQUESTS:
|
| 48 |
+
raise HTTPException(
|
| 49 |
+
status_code=429,
|
| 50 |
+
detail="Rate limit exceeded: max "
|
| 51 |
+
f"{RATE_LIMIT_MAX_REQUESTS} analyses per hour per client."
|
| 52 |
+
)
|
| 53 |
+
window.append(now)
|
| 54 |
+
|
| 55 |
|
| 56 |
@router.post("/analyze", response_model=WorkflowStartResponse)
|
| 57 |
+
async def start_analysis(request: AnalysisRequest, http_request: Request):
|
| 58 |
"""Start a new SWOT analysis workflow."""
|
| 59 |
+
_evict_stale_workflows()
|
| 60 |
+
|
| 61 |
+
client_ip = http_request.client.host if http_request.client else "unknown"
|
| 62 |
+
_check_rate_limit(client_ip)
|
| 63 |
+
|
| 64 |
+
active = sum(
|
| 65 |
+
1 for wf in WORKFLOWS.values()
|
| 66 |
+
if wf.get("status") in ("starting", "running")
|
| 67 |
+
)
|
| 68 |
+
if active >= MAX_ACTIVE_WORKFLOWS:
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=429,
|
| 71 |
+
detail="Server busy: too many concurrent analyses. Retry shortly."
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
workflow_id = str(uuid.uuid4())
|
| 75 |
|
| 76 |
# Initialize workflow state
|
| 77 |
WORKFLOWS[workflow_id] = {
|
| 78 |
+
"created_at": time.time(),
|
| 79 |
"status": "starting",
|
| 80 |
"current_step": "input",
|
| 81 |
"revision_count": 0,
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the self-correcting loop routing logic."""
|
| 2 |
+
|
| 3 |
+
from src.utils.conditions import should_continue
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_exits_on_error():
|
| 7 |
+
assert should_continue({"error": "LLM providers failed", "score": 2}) == "exit"
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_exits_when_revision_skipped():
|
| 11 |
+
state = {"analyzer_revision_skipped": True, "score": 3, "revision_count": 0}
|
| 12 |
+
assert should_continue(state) == "exit"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_exits_on_escalate():
|
| 16 |
+
state = {
|
| 17 |
+
"critique_details": {"status": "ESCALATE"},
|
| 18 |
+
"score": 3,
|
| 19 |
+
"revision_count": 1,
|
| 20 |
+
}
|
| 21 |
+
assert should_continue(state) == "exit"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_exits_on_good_score():
|
| 25 |
+
assert should_continue({"score": 6, "revision_count": 0}) == "exit"
|
| 26 |
+
assert should_continue({"score": 9.5, "revision_count": 0}) == "exit"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_exits_at_max_revisions():
|
| 30 |
+
assert should_continue({"score": 4, "revision_count": 3}) == "exit"
|
| 31 |
+
assert should_continue({"score": 4, "revision_count": 5}) == "exit"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_retries_on_low_score_with_revisions_left():
|
| 35 |
+
assert should_continue({"score": 5.0, "revision_count": 0}) == "retry"
|
| 36 |
+
assert should_continue({"score": 5.9, "revision_count": 2}) == "retry"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_retries_with_missing_defaults():
|
| 40 |
+
# No score/revision_count in state defaults to 0/0 -> retry
|
| 41 |
+
assert should_continue({}) == "retry"
|
|
@@ -1,79 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Comprehensive test for self-correction mechanisms in the SWOT Analysis Agent
|
| 4 |
-
Tests multiple failure scenarios to verify the self-correcting loop functionality.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import sys
|
| 8 |
-
import os
|
| 9 |
-
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 10 |
-
|
| 11 |
-
from src.graph_cyclic import run_self_correcting_workflow
|
| 12 |
-
|
| 13 |
-
def test_analyzer_failure():
|
| 14 |
-
"""Test self-correction when analyzer produces poor quality output"""
|
| 15 |
-
print("π§ͺ Testing Analyzer Failure Scenario...")
|
| 16 |
-
|
| 17 |
-
# Monkey patch the analyzer node to force poor quality
|
| 18 |
-
def force_poor_analyzer(state):
|
| 19 |
-
"""Force a poor quality draft to trigger revision loop"""
|
| 20 |
-
state["draft_report"] = "Bad analysis. No details. Incomplete."
|
| 21 |
-
print("β οΈ FORCED POOR QUALITY: Overriding with very weak content")
|
| 22 |
-
return state
|
| 23 |
-
|
| 24 |
-
# Temporarily replace analyzer in the workflow
|
| 25 |
-
import src.nodes.analyzer
|
| 26 |
-
original_analyzer = src.nodes.analyzer.analyzer_node
|
| 27 |
-
src.nodes.analyzer.analyzer_node = force_poor_analyzer
|
| 28 |
-
|
| 29 |
-
try:
|
| 30 |
-
result = run_self_correcting_workflow("Test Company")
|
| 31 |
-
print(f"β
Test completed with {result['revision_count']} revisions")
|
| 32 |
-
print(f"π Final score: {result['score']}/10")
|
| 33 |
-
finally:
|
| 34 |
-
# Restore original function
|
| 35 |
-
src.nodes.analyzer.analyzer_node = original_analyzer
|
| 36 |
-
|
| 37 |
-
def test_critic_failure():
|
| 38 |
-
"""Test self-correction when critic gives low scores"""
|
| 39 |
-
print("\nπ§ͺ Testing Critic Failure Scenario...")
|
| 40 |
-
|
| 41 |
-
# Monkey patch the critic to force a low score
|
| 42 |
-
def force_low_score_critic(state):
|
| 43 |
-
"""Force a low score to trigger revision loop"""
|
| 44 |
-
state["score"] = 3 # Low score to force revision
|
| 45 |
-
state["critique"] = "Forced low score for testing self-correction loop"
|
| 46 |
-
print("β οΈ FORCED LOW SCORE: 3/10 to trigger revision loop")
|
| 47 |
-
return state
|
| 48 |
-
|
| 49 |
-
# Temporarily replace critic in the workflow
|
| 50 |
-
import src.nodes.critic
|
| 51 |
-
original_critic = src.nodes.critic.critic_node
|
| 52 |
-
src.nodes.critic.critic_node = force_low_score_critic
|
| 53 |
-
|
| 54 |
-
try:
|
| 55 |
-
result = run_self_correcting_workflow("Test Company")
|
| 56 |
-
print(f"β
Test completed with {result['revision_count']} revisions")
|
| 57 |
-
print(f"π Final score: {result['score']}/10")
|
| 58 |
-
finally:
|
| 59 |
-
# Restore original function
|
| 60 |
-
src.nodes.critic.critic_node = original_critic
|
| 61 |
-
|
| 62 |
-
def test_workflow_failure():
|
| 63 |
-
"""Test self-correction with custom workflow manipulation"""
|
| 64 |
-
print("\nπ§ͺ Testing Workflow Failure Scenario...")
|
| 65 |
-
|
| 66 |
-
# This test would implement the custom workflow approach from test_force_failure.py
|
| 67 |
-
# For brevity, we'll just indicate this as a placeholder
|
| 68 |
-
print("π Custom workflow failure test placeholder")
|
| 69 |
-
print("β
Test framework ready for custom workflow testing")
|
| 70 |
-
|
| 71 |
-
if __name__ == "__main__":
|
| 72 |
-
print("π Running Self-Correction Test Suite")
|
| 73 |
-
print("=" * 50)
|
| 74 |
-
|
| 75 |
-
test_analyzer_failure()
|
| 76 |
-
test_critic_failure()
|
| 77 |
-
test_workflow_failure()
|
| 78 |
-
|
| 79 |
-
print("\nπ All self-correction tests completed!")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|