vn6295337 commited on
Commit
e926e7e
Β·
1 Parent(s): 182dc1c

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 CHANGED
@@ -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
 
Dockerfile CHANGED
@@ -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 (use npm install to resolve version mismatches)
13
- RUN npm install --legacy-peer-deps
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/ ./
docs/audit_checklist.md CHANGED
@@ -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
- - [ ] After redeploy, fetch the served JS bundle and confirm it contains no `execute-api` URL
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
- - [ ] Pin Python dependencies with a lockfile (`uv pip compile` or `pip-compile`) for Instant-SWOT-Agent and rebuild
15
- - [ ] Pin Python dependencies for Researcher-Agent and its MCP server requirements files
16
- - [ ] Change Dockerfile frontend stage from `npm install --legacy-peer-deps` to `npm ci`
17
- - [ ] Verify a clean-clone Docker build succeeds with pinned deps (LangGraph now resolves to 1.x; confirm StateGraph workflow still runs)
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
- - [ ] Add a deep health check that pings each configured LLM with a 1-token request instead of only checking key presence
23
  - [x] Check the Supabase project is not paused (confirmed working 2026-07-17: https://atilxlecbaqcksnrgzav.supabase.co)
24
- - [ ] Document `PIPELINE_SUPABASE_URL` in `.env.example` and CLAUDE.md
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
- - [ ] Remove the now-dead `awsDeployment/` directory and AWS references from the repo (or archive to a branch)
29
- - [ ] Fix CORS in `src/api/app.py`: replace ineffective literal `https://*.hf.space` with `allow_origin_regex`, drop `allow_credentials=True`
30
- - [ ] Add per-IP rate limiting (e.g., `slowapi`) and a concurrent-workflow cap on `POST /analyze`
31
- - [ ] Add TTL eviction for the in-memory `WORKFLOWS` dict in `src/services/workflow_store.py` and the task dict in Researcher-Agent `app.py`
32
- - [ ] Update README.md and CLAUDE.md to match the real workflow (no Editor node, exit threshold 6 not 7)
33
- - [ ] Delete dead code: `src/graph_cyclic.py`
34
- - [ ] Remove unused deps from `requirements.txt`: `langchain-groq`, `google-generativeai`, `tavily-python`, `mcp`
35
- - [ ] Fix stale `pyproject.toml`: project name, Streamlit deps, wrong repo URLs, broken `app:main` console script
36
  - [x] Remove repo noise from Researcher-Agent: `reports/` and `.obsidian/` untracked + gitignored (pushed 2026-07-18)
37
- - [ ] Delete the local 265MB `awsDeployment/build/` directory
38
  - [ ] Switch to structured JSON outputs (`response_format: json_object`) for critic/analyzer calls on Groq/OpenRouter
39
- - [ ] Replace deprecated `@app.on_event("startup")` with a lifespan handler in `src/api/app.py`
40
- - [ ] Fix local dev environment: `pip install -r requirements.txt` (pytest currently fails on missing `vaderSentiment`) and get the test suite green
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
- - [ ] Add cache headers to FastAPI `StaticFiles` serving of the frontend bundle
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
requirements.in ADDED
@@ -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
requirements.txt CHANGED
@@ -1,30 +1,196 @@
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
+ # 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
src/api/app.py CHANGED
@@ -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
- allow_credentials=True,
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."""
src/api/routes/analysis.py CHANGED
@@ -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,
tests/test_conditions.py ADDED
@@ -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"
tests/test_self_correcting_loop.py DELETED
@@ -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!")