v0.21.1: Fix SSE timeout + add diagnostic logging
Browse files- Disable proxy buffering (X-Accel-Buffering: no) so heartbeats
actually reach the client through HF Spaces nginx
- Increase uvicorn timeout-keep-alive to 300s
- Add 10-minute AbortSignal.timeout on frontend fetch
- Add server-side logging (Python logging) for post-L5 stages
- Add SSE log events with timing for Polish, Judge, Report
- Improve frontend error display: shows elapsed time, last active
layer, and human-readable message for network/timeout errors
- Bump version to v0.21.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CHANGELOG.md +6 -0
- Dockerfile +1 -1
- main.py +36 -4
- static/index.html +2 -2
- static/js/app.js +16 -2
CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
# CRCS Hybrid Engine β Changelog
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## v0.21.0 (2026-04-05)
|
| 4 |
- **Architecture matches Companion Paper exactly** β two-phase lattice protocol:
|
| 5 |
- **Phase 1** (single pass): L1βL2βL3β₯L3AβL4βL5 β select m* = argmax Score(m)
|
|
|
|
| 1 |
# CRCS Hybrid Engine β Changelog
|
| 2 |
|
| 3 |
+
## v0.21.1 (2026-04-06)
|
| 4 |
+
- **Fix SSE network error on HF Spaces** β heartbeat keepalive every 15s prevents proxy timeout
|
| 5 |
+
- **Disable proxy buffering** β `X-Accel-Buffering: no` header ensures heartbeats reach client immediately
|
| 6 |
+
- **Increase uvicorn keep-alive** β 300s timeout-keep-alive in Dockerfile
|
| 7 |
+
- **Frontend timeout** β 10-minute AbortSignal.timeout on fetch to prevent browser-side abort
|
| 8 |
+
|
| 9 |
## v0.21.0 (2026-04-05)
|
| 10 |
- **Architecture matches Companion Paper exactly** β two-phase lattice protocol:
|
| 11 |
- **Phase 1** (single pass): L1βL2βL3β₯L3AβL4βL5 β select m* = argmax Score(m)
|
Dockerfile
CHANGED
|
@@ -18,4 +18,4 @@ RUN python3 -c "import json,subprocess,datetime; \
|
|
| 18 |
|
| 19 |
EXPOSE 7860
|
| 20 |
|
| 21 |
-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 18 |
|
| 19 |
EXPOSE 7860
|
| 20 |
|
| 21 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860", "--timeout-keep-alive", "300"]
|
main.py
CHANGED
|
@@ -9,11 +9,15 @@ Endpoints:
|
|
| 9 |
import asyncio
|
| 10 |
import datetime
|
| 11 |
import json
|
|
|
|
| 12 |
import os
|
| 13 |
import time
|
| 14 |
from pathlib import Path
|
| 15 |
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
|
|
|
|
|
|
|
|
|
|
| 17 |
from dotenv import load_dotenv
|
| 18 |
load_dotenv()
|
| 19 |
|
|
@@ -741,6 +745,8 @@ async def run_pipeline(request: Request):
|
|
| 741 |
|
| 742 |
# ββ Polish: add citations and formatting to stable m* ββββββββ
|
| 743 |
# This happens AFTER Phase 2 convergence β polish is presentation, not constraint
|
|
|
|
|
|
|
| 744 |
yield send("status", {"msg": "Polishing stable m* with citationsβ¦"})
|
| 745 |
yield send("layer", {"id": "L5P", "name": "Polish β Citations & Formatting", "status": "running", "model": MODEL_CONSTRAINT})
|
| 746 |
try:
|
|
@@ -748,9 +754,12 @@ async def run_pipeline(request: Request):
|
|
| 748 |
_executor,
|
| 749 |
lambda: polish_mstar(client, best_answer, query, domain,
|
| 750 |
all_evidence_text, all_sources, add))
|
|
|
|
|
|
|
| 751 |
for e in log_entries:
|
| 752 |
yield send("log", e)
|
| 753 |
log_entries.clear()
|
|
|
|
| 754 |
yield send("layer", {"id": "L5P", "status": "pass"})
|
| 755 |
yield send("polished", {"answer": polished_answer,
|
| 756 |
"sources": [{"title": s.get("title",""),
|
|
@@ -758,7 +767,8 @@ async def run_pipeline(request: Request):
|
|
| 758 |
"year": s.get("year","n.d.")}
|
| 759 |
for s in all_sources[:8]]})
|
| 760 |
except Exception as e:
|
| 761 |
-
|
|
|
|
| 762 |
yield send("layer", {"id": "L5P", "status": "fail"})
|
| 763 |
polished_answer = best_answer # fallback to unpolished
|
| 764 |
|
|
@@ -786,7 +796,10 @@ async def run_pipeline(request: Request):
|
|
| 786 |
# ββ LLM Judge: Baseline vs CRCS ββββββββββββββββββββββββββββββ
|
| 787 |
judge_result = None
|
| 788 |
if baseline_answer:
|
|
|
|
|
|
|
| 789 |
yield send("status", {"msg": "Running LLM judge comparisonβ¦"})
|
|
|
|
| 790 |
yield send("layer", {"id": "JUDGE", "name": "LLM Judge β Baseline vs CRCS", "status": "running", "model": MODEL_CONSTRAINT})
|
| 791 |
try:
|
| 792 |
_dim_schema = {"type": "object", "properties": {
|
|
@@ -847,10 +860,14 @@ async def run_pipeline(request: Request):
|
|
| 847 |
|
| 848 |
judge_result = _map_scores(raw_judge, a_is_baseline)
|
| 849 |
judge_result["_a_is_baseline"] = a_is_baseline
|
|
|
|
|
|
|
|
|
|
| 850 |
yield send("layer", {"id": "JUDGE", "status": "pass"})
|
| 851 |
yield send("judge", judge_result)
|
| 852 |
except Exception as e:
|
| 853 |
-
|
|
|
|
| 854 |
yield send("layer", {"id": "JUDGE", "status": "fail"})
|
| 855 |
|
| 856 |
# ββ Provenance ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -874,18 +891,25 @@ async def run_pipeline(request: Request):
|
|
| 874 |
|
| 875 |
# ββ Report generation βββββββββββββββββββββββββββββββββββββββββ
|
| 876 |
report_id = None
|
|
|
|
|
|
|
| 877 |
yield send("status", {"msg": "Generating reportβ¦"})
|
|
|
|
| 878 |
try:
|
| 879 |
from report import generate_report, save_temp
|
| 880 |
report_md = await loop.run_in_executor(_executor, lambda: generate_report(
|
| 881 |
client, MODEL_GENERATION, query, domain, weights,
|
| 882 |
iterations, boundary, all_sources,
|
| 883 |
baseline=baseline_answer))
|
|
|
|
|
|
|
|
|
|
| 884 |
report_id = secrets.token_hex(8)
|
| 885 |
_reports[report_id] = {"md": report_md, "query": query, "domain": domain}
|
| 886 |
yield send("report", {"report_id": report_id, "length": len(report_md)})
|
| 887 |
except Exception as e:
|
| 888 |
-
|
|
|
|
| 889 |
|
| 890 |
# ββ L6: S-genuine review gate β BLOCKS until decision ββββββββ
|
| 891 |
review_required = sims["high_stakes"]
|
|
@@ -979,7 +1003,15 @@ async def run_pipeline(request: Request):
|
|
| 979 |
# No event in `interval` seconds β send keepalive
|
| 980 |
yield ": heartbeat\n\n"
|
| 981 |
|
| 982 |
-
return StreamingResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
|
| 984 |
|
| 985 |
# ββ Entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 9 |
import asyncio
|
| 10 |
import datetime
|
| 11 |
import json
|
| 12 |
+
import logging
|
| 13 |
import os
|
| 14 |
import time
|
| 15 |
from pathlib import Path
|
| 16 |
from concurrent.futures import ThreadPoolExecutor
|
| 17 |
|
| 18 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
| 19 |
+
log = logging.getLogger("crcs")
|
| 20 |
+
|
| 21 |
from dotenv import load_dotenv
|
| 22 |
load_dotenv()
|
| 23 |
|
|
|
|
| 745 |
|
| 746 |
# ββ Polish: add citations and formatting to stable m* ββββββββ
|
| 747 |
# This happens AFTER Phase 2 convergence β polish is presentation, not constraint
|
| 748 |
+
log.info("POST-L5: starting Polish")
|
| 749 |
+
_t_polish = time.time()
|
| 750 |
yield send("status", {"msg": "Polishing stable m* with citationsβ¦"})
|
| 751 |
yield send("layer", {"id": "L5P", "name": "Polish β Citations & Formatting", "status": "running", "model": MODEL_CONSTRAINT})
|
| 752 |
try:
|
|
|
|
| 754 |
_executor,
|
| 755 |
lambda: polish_mstar(client, best_answer, query, domain,
|
| 756 |
all_evidence_text, all_sources, add))
|
| 757 |
+
_dt = time.time() - _t_polish
|
| 758 |
+
log.info(f"POST-L5: Polish done ({_dt:.1f}s)")
|
| 759 |
for e in log_entries:
|
| 760 |
yield send("log", e)
|
| 761 |
log_entries.clear()
|
| 762 |
+
yield send("log", {"level": "INFO", "msg": f"Polish completed ({_dt:.1f}s)", "time": ts()})
|
| 763 |
yield send("layer", {"id": "L5P", "status": "pass"})
|
| 764 |
yield send("polished", {"answer": polished_answer,
|
| 765 |
"sources": [{"title": s.get("title",""),
|
|
|
|
| 767 |
"year": s.get("year","n.d.")}
|
| 768 |
for s in all_sources[:8]]})
|
| 769 |
except Exception as e:
|
| 770 |
+
log.error(f"POST-L5: Polish FAILED after {time.time()-_t_polish:.1f}s: {e}")
|
| 771 |
+
yield send("log", {"level": "ERROR", "msg": f"Polish failed ({time.time()-_t_polish:.1f}s): {e}", "time": ts()})
|
| 772 |
yield send("layer", {"id": "L5P", "status": "fail"})
|
| 773 |
polished_answer = best_answer # fallback to unpolished
|
| 774 |
|
|
|
|
| 796 |
# ββ LLM Judge: Baseline vs CRCS ββββββββββββββββββββββββββββββ
|
| 797 |
judge_result = None
|
| 798 |
if baseline_answer:
|
| 799 |
+
log.info("POST-L5: starting Judge")
|
| 800 |
+
_t_judge = time.time()
|
| 801 |
yield send("status", {"msg": "Running LLM judge comparisonβ¦"})
|
| 802 |
+
yield send("log", {"level": "INFO", "msg": "Starting LLM Judgeβ¦", "time": ts()})
|
| 803 |
yield send("layer", {"id": "JUDGE", "name": "LLM Judge β Baseline vs CRCS", "status": "running", "model": MODEL_CONSTRAINT})
|
| 804 |
try:
|
| 805 |
_dim_schema = {"type": "object", "properties": {
|
|
|
|
| 860 |
|
| 861 |
judge_result = _map_scores(raw_judge, a_is_baseline)
|
| 862 |
judge_result["_a_is_baseline"] = a_is_baseline
|
| 863 |
+
_dt = time.time() - _t_judge
|
| 864 |
+
log.info(f"POST-L5: Judge done ({_dt:.1f}s)")
|
| 865 |
+
yield send("log", {"level": "INFO", "msg": f"Judge completed ({_dt:.1f}s)", "time": ts()})
|
| 866 |
yield send("layer", {"id": "JUDGE", "status": "pass"})
|
| 867 |
yield send("judge", judge_result)
|
| 868 |
except Exception as e:
|
| 869 |
+
log.error(f"POST-L5: Judge FAILED after {time.time()-_t_judge:.1f}s: {e}")
|
| 870 |
+
yield send("log", {"level": "ERROR", "msg": f"Judge failed ({time.time()-_t_judge:.1f}s): {e}", "time": ts()})
|
| 871 |
yield send("layer", {"id": "JUDGE", "status": "fail"})
|
| 872 |
|
| 873 |
# ββ Provenance ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 891 |
|
| 892 |
# ββ Report generation βββββββββββββββββββββββββββββββββββββββββ
|
| 893 |
report_id = None
|
| 894 |
+
log.info("POST-L5: starting Report")
|
| 895 |
+
_t_report = time.time()
|
| 896 |
yield send("status", {"msg": "Generating reportβ¦"})
|
| 897 |
+
yield send("log", {"level": "INFO", "msg": "Starting report generationβ¦", "time": ts()})
|
| 898 |
try:
|
| 899 |
from report import generate_report, save_temp
|
| 900 |
report_md = await loop.run_in_executor(_executor, lambda: generate_report(
|
| 901 |
client, MODEL_GENERATION, query, domain, weights,
|
| 902 |
iterations, boundary, all_sources,
|
| 903 |
baseline=baseline_answer))
|
| 904 |
+
_dt = time.time() - _t_report
|
| 905 |
+
log.info(f"POST-L5: Report done ({_dt:.1f}s)")
|
| 906 |
+
yield send("log", {"level": "INFO", "msg": f"Report generated ({_dt:.1f}s)", "time": ts()})
|
| 907 |
report_id = secrets.token_hex(8)
|
| 908 |
_reports[report_id] = {"md": report_md, "query": query, "domain": domain}
|
| 909 |
yield send("report", {"report_id": report_id, "length": len(report_md)})
|
| 910 |
except Exception as e:
|
| 911 |
+
log.error(f"POST-L5: Report FAILED after {time.time()-_t_report:.1f}s: {e}")
|
| 912 |
+
yield send("log", {"level": "ERROR", "msg": f"Report failed ({time.time()-_t_report:.1f}s): {e}", "time": ts()})
|
| 913 |
|
| 914 |
# ββ L6: S-genuine review gate β BLOCKS until decision ββββββββ
|
| 915 |
review_required = sims["high_stakes"]
|
|
|
|
| 1003 |
# No event in `interval` seconds β send keepalive
|
| 1004 |
yield ": heartbeat\n\n"
|
| 1005 |
|
| 1006 |
+
return StreamingResponse(
|
| 1007 |
+
heartbeat_wrap(event_stream()),
|
| 1008 |
+
media_type="text/event-stream",
|
| 1009 |
+
headers={
|
| 1010 |
+
"Cache-Control": "no-cache, no-transform",
|
| 1011 |
+
"X-Accel-Buffering": "no", # nginx: disable proxy buffering
|
| 1012 |
+
"Connection": "keep-alive",
|
| 1013 |
+
},
|
| 1014 |
+
)
|
| 1015 |
|
| 1016 |
|
| 1017 |
# ββ Entry point ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
static/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
|
| 15 |
<nav>
|
| 16 |
<div class="nav-inner">
|
| 17 |
-
<div class="logo">CRCS<small>HYBRID ENGINE v0.21</small></div>
|
| 18 |
<div class="nav-right">
|
| 19 |
<button class="tab-btn active" data-tab="pipeline" onclick="switchTab('pipeline', this)">Pipeline</button>
|
| 20 |
<button class="tab-btn" data-tab="kg" onclick="switchTab('kg', this)">KG Rules</button>
|
|
@@ -131,7 +131,7 @@
|
|
| 131 |
|
| 132 |
</div>
|
| 133 |
|
| 134 |
-
<footer>CRCS Hybrid Engine v0.21.
|
| 135 |
|
| 136 |
<script src="/static/js/app.js"></script>
|
| 137 |
</body>
|
|
|
|
| 14 |
|
| 15 |
<nav>
|
| 16 |
<div class="nav-inner">
|
| 17 |
+
<div class="logo">CRCS<small>HYBRID ENGINE v0.21.1</small></div>
|
| 18 |
<div class="nav-right">
|
| 19 |
<button class="tab-btn active" data-tab="pipeline" onclick="switchTab('pipeline', this)">Pipeline</button>
|
| 20 |
<button class="tab-btn" data-tab="kg" onclick="switchTab('kg', this)">KG Rules</button>
|
|
|
|
| 131 |
|
| 132 |
</div>
|
| 133 |
|
| 134 |
+
<footer>CRCS Hybrid Engine v0.21.1 — Constraint-Regulated Cognition — <span id="buildInfo" style="font-family:var(--mono);font-size:0.7rem;"></span></footer>
|
| 135 |
|
| 136 |
<script src="/static/js/app.js"></script>
|
| 137 |
</body>
|
static/js/app.js
CHANGED
|
@@ -325,6 +325,8 @@ async function runPipelineSSE() {
|
|
| 325 |
max_iter: 3,
|
| 326 |
n_candidates: 3,
|
| 327 |
}),
|
|
|
|
|
|
|
| 328 |
});
|
| 329 |
|
| 330 |
if (!resp.ok) {
|
|
@@ -850,9 +852,21 @@ async function runPipelineSSE() {
|
|
| 850 |
}
|
| 851 |
}
|
| 852 |
} catch (e) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
area.insertAdjacentHTML('beforeend',
|
| 854 |
-
`<div class="error-note"><strong>Pipeline error:</strong> ${esc(
|
| 855 |
-
|
|
|
|
| 856 |
}
|
| 857 |
|
| 858 |
state.running = false;
|
|
|
|
| 325 |
max_iter: 3,
|
| 326 |
n_candidates: 3,
|
| 327 |
}),
|
| 328 |
+
// 10-minute timeout β pipeline can take several minutes for all layers
|
| 329 |
+
signal: AbortSignal.timeout(600000),
|
| 330 |
});
|
| 331 |
|
| 332 |
if (!resp.ok) {
|
|
|
|
| 852 |
}
|
| 853 |
}
|
| 854 |
} catch (e) {
|
| 855 |
+
// Find which layer was last running to show context
|
| 856 |
+
const runningLayers = area.querySelectorAll('.layer-card');
|
| 857 |
+
let lastLayer = 'unknown';
|
| 858 |
+
runningLayers.forEach(el => {
|
| 859 |
+
const nameEl = el.querySelector('.layer-name');
|
| 860 |
+
if (nameEl) lastLayer = nameEl.textContent;
|
| 861 |
+
});
|
| 862 |
+
const elapsed = ((Date.now() - dbgRunStart) / 1000).toFixed(0);
|
| 863 |
+
const detail = e.name === 'TimeoutError' ? 'Browser timeout (10 min) β pipeline took too long'
|
| 864 |
+
: e.message === 'network error' ? 'Connection dropped β likely proxy timeout on HF Spaces'
|
| 865 |
+
: e.message;
|
| 866 |
area.insertAdjacentHTML('beforeend',
|
| 867 |
+
`<div class="error-note"><strong>Pipeline error:</strong> ${esc(detail)}<br>` +
|
| 868 |
+
`<small style="color:var(--muted)">After ${elapsed}s Β· Last active: ${esc(lastLayer)}</small></div>`);
|
| 869 |
+
dbg('ERROR', '', `Pipeline error after ${elapsed}s at "${lastLayer}": ${e.message}`);
|
| 870 |
}
|
| 871 |
|
| 872 |
state.running = false;
|