Skip to content

Commit 25941cc

Browse files
author
Denis Ermilov
committed
Persist heuristic fallback results in cache
1 parent b62348f commit 25941cc

5 files changed

Lines changed: 71 additions & 13 deletions

File tree

PROJECT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -653,7 +653,7 @@ FastAPI server wrapping local AI CLIs. Runs on port 8765 (configurable via `PORT
653653
4. Only tabs without fresh coverage are formatted into compact prompts and analyzed in batches of 30 via the configured CLI provider
654654
5. If the primary CLI fails or hits a usage limit, the server retries the batch with the fallback CLI provider
655655
6. The extension persists partial/final run state with per-tab statuses in `analysis_runs`, so stop/resume survives reloads
656-
7. If a batch finishes via heuristic fallback in the extension, those per-URL results are imported back through `/url-analysis/import`
656+
7. If a batch falls back to heuristics, those per-URL results are still persisted to `url_analysis` either directly on the server or via `/url-analysis/import`, so per-tab coverage stays in sync with what the user just analyzed
657657
8. The server stores per-URL results + session metrics in SQLite, then returns the aggregated `AnalyzeResponse`
658658
9. The Search dialog uses `/chat` to retrieve SQLite candidates and, when useful, summarize/rank them with the same provider/model chain as AI Analysis
659659

SETUP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ The AI server is a FastAPI application that proxies tab analysis requests to CLI
199199
3. Server checks `tab_analysis.db` for fresh per-URL cache hits (180-day TTL, namespaced by provider/model settings)
200200
4. Only tabs without fresh SQLite coverage are sent to the configured CLI provider, in batches of 30, unless `Re-analyze` is used
201201
5. If the primary provider fails or hits a usage limit, the server automatically tries the fallback CLI provider
202-
6. If both configured providers fail, heuristic recommendations keep the batch moving and the UI still receives structured results
202+
6. If both configured providers fail, the server saves heuristic recommendations into `url_analysis`, so the batch still produces structured results and the same tabs stop appearing as "not yet analyzed"
203203
7. After each batch, the extension persists partial/final analysis run state in SQLite (`analysis_runs`) with per-tab statuses so stop/resume survives extension reloads
204204
8. If a batch finishes through the client-side heuristic fallback, those per-URL results are imported back into SQLite through `/url-analysis/import`, so coverage stays accurate
205205
9. Server stores per-URL results + session metrics in SQLite, then returns the aggregated result, metadata, and cache stats

agent.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2272,30 +2272,46 @@ async def analyze(request: AnalyzeRequest, req: Request) -> AnalyzeResponse:
22722272
error_msg = summarize_provider_error(provider_error) if provider_error else "No provider configured"
22732273
fallback_notice = classify_fallback_issue(provider_error or RuntimeError("No provider configured"))
22742274
fallback_notices.append(fallback_notice)
2275+
heuristic_reason = f"{fallback_notice} Original provider error: {error_msg}."
2276+
heuristic_recommendations = build_heuristic_recommendations(batch, heuristic_reason)
2277+
now = time.time()
2278+
db_entries = []
2279+
for rec in heuristic_recommendations:
2280+
tab = next((t for t in batch if t.id == rec.get("tabId")), None)
2281+
if tab:
2282+
db_entries.append({
2283+
"url": f"{cache_namespace}::{tab.url}",
2284+
"action": rec["action"],
2285+
"confidence": rec["confidence"],
2286+
"reason": rec["reason"],
2287+
"suggestedGroupName": rec.get("suggestedGroupName"),
2288+
"analyzedAt": now,
2289+
"analysisSource": "heuristic",
2290+
"provider": None,
2291+
"model": None,
2292+
})
2293+
all_recommendations.append(rec)
2294+
2295+
await save_url_analyses(db, db_entries)
2296+
tabs_saved += len(db_entries)
2297+
22752298
failed_urls = [tab.url for tab in batch]
22762299
logger.error(
2277-
"Batch %s/%s FAILED (not saved to DB): %s. Failed URLs: %s",
2300+
"Batch %s/%s provider fallback saved as heuristics: %s. Failed URLs: %s",
22782301
batch_idx + 1,
22792302
len(batches),
22802303
error_msg,
22812304
", ".join(url[:80] for url in failed_urls[:5]) + (f" (+{len(failed_urls)-5} more)" if len(failed_urls) > 5 else ""),
22822305
)
22832306
await add_runtime_log(
22842307
db,
2285-
"error",
2308+
"warning",
22862309
"provider",
22872310
f"Batch {batch_idx + 1}/{len(batches)}: all providers failed — {error_msg}. "
2288-
f"{len(batch)} URLs skipped (not saved): "
2311+
f"{len(db_entries)} heuristic URL analysis record(s) saved instead: "
22892312
+ ", ".join(url[:60] for url in failed_urls[:5])
22902313
+ (f" (+{len(failed_urls)-5} more)" if len(failed_urls) > 5 else ""),
22912314
)
2292-
for tab in batch:
2293-
all_recommendations.append({
2294-
"tabId": tab.id,
2295-
"action": "keep",
2296-
"confidence": 0.0,
2297-
"reason": f"Error: {error_msg}. This tab was not analyzed.",
2298-
})
22992315

23002316
# Step 4: Build full result
23012317
fallback_summary = fallback_notices[0] if fallback_notices else None

docs/testing/TEST_PLAN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ Each test function gets a fresh in-memory SQLite database via the `client` fixtu
105105
| R18 | Invalid URL | No exception thrown for non-URL string |
106106
| R19 | Invalid URL near-dup | Invalid URL tab not flagged as near-duplicate of valid tab |
107107

108-
### 3.3 Python — FastAPI server + runtime behavior (28 tests)
108+
### 3.3 Python — FastAPI server + runtime behavior (29 tests)
109109

110110
| ID | Endpoint | Description |
111111
|---|---|---|
@@ -119,6 +119,7 @@ Each test function gets a fresh in-memory SQLite database via the `client` fixtu
119119
| P08 | `POST /url-analysis/import` | Single result saved (saved ≥ 1) |
120120
| P09 | `POST /url-analysis/import` | Empty payload returns `{ saved: 0 }` |
121121
| P10 | `POST /url-analysis/import` | Multiple results counted correctly |
122+
| P10a | `POST /analyze` + `POST /tab-analysis-status` | Provider timeout falls back to heuristics, saves results to SQLite, and marks tabs as cached on the next coverage check |
122123
| P11 | `GET /clusters` | Empty database returns `{ clusters: [] }` |
123124
| P12 | `POST /clusters/merge` | Creates a new cluster, visible in list |
124125
| P13 | `PUT /clusters/{id}` | Renames cluster, name reflected in list |

tests/test_runtime_behavior.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,44 @@ async def fake_analyze_batch_via_provider(provider: str, batch: list[agent.TabIn
143143
response = client.post("/analyze", json={"tabs": tabs, "forceRefresh": True})
144144
assert response.status_code == 200
145145
assert calls == ["claude_code", "codex_cli", "codex_cli"]
146+
147+
148+
def test_analyze_saves_heuristic_fallback_results_to_cache(client, monkeypatch):
149+
client.post("/settings", json={"settings": {"serverAiProvider": "codex_cli", "fallbackAiProvider": "none"}})
150+
151+
async def fake_analyze_batch_via_provider(provider: str, batch: list[agent.TabInput], settings: agent.AppSettings, **kwargs):
152+
raise RuntimeError("Codex CLI timed out after 60s")
153+
154+
monkeypatch.setattr(agent, "analyze_batch_via_provider", fake_analyze_batch_via_provider)
155+
156+
tabs = [
157+
{
158+
"id": 1,
159+
"url": "https://example.com/a",
160+
"title": "Tab A",
161+
"domain": "example.com",
162+
"pinned": False,
163+
"active": False,
164+
},
165+
{
166+
"id": 2,
167+
"url": "https://example.com/b",
168+
"title": "Tab B",
169+
"domain": "example.com",
170+
"pinned": False,
171+
"active": True,
172+
},
173+
]
174+
175+
response = client.post("/analyze", json={"tabs": tabs, "forceRefresh": True})
176+
assert response.status_code == 200
177+
data = response.json()
178+
assert data["cacheStats"]["tabsSaved"] == 2
179+
assert len(data["result"]["tabRecommendations"]) == 2
180+
181+
status_response = client.post("/tab-analysis-status", json={"tabs": tabs})
182+
assert status_response.status_code == 200
183+
status_data = status_response.json()
184+
assert status_data["summary"]["pending"] == 0
185+
assert status_data["summary"]["cached"] == 2
186+
assert all(status["status"] == "cached" for status in status_data["statuses"])

0 commit comments

Comments
 (0)