Spaces:
Sleeping
Sleeping
- Updated `.env.example` to include new settings for bulk upload limits and batch processing.
3c31a2a | """ | |
| Full end-to-end smoke test for the RICS Report Genius AI system. | |
| Flow: | |
| 1. Notes-expander unit check (rule-based abbreviations) | |
| 2. Health check | |
| 3. Ingest a sample DOCX via /test/ingest | |
| 4. Create a report linked to that document | |
| 5. Generate section D (About the property) with messy notes, default ai_level=3 (Balanced) | |
| 6. Poll until complete, print result + style profile | |
| 7. Re-generate section D at ai_level=1 (RAG Only) β should still succeed | |
| 8. Proofread section D | |
| 9. Enhance section D | |
| 10. Style-profile roundtrip: all 7 fields must be present | |
| 11. /extract-notes endpoint: upload the sample DOCX, verify lines returned | |
| 12. /upload/batch endpoint: upload the sample DOCX as a batch, verify doc row | |
| 13. /documents/batch-status endpoint: poll the batch-uploaded doc | |
| 14. /documents/tenant-chunk-summary endpoint: returns chunk count | |
| 15. Oversized notes-file rejection: expect 413 from /extract-notes | |
| """ | |
| import io | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import requests | |
| from app.generator.notes_expander import expand_notes | |
| BASE = "http://localhost:8000" | |
| TENANT = "e2e-smoke-test" | |
| HEADERS = {"X-Tenant-ID": TENANT} | |
| JSON_HEADERS = {**HEADERS, "Content-Type": "application/json"} | |
| # Resolve relative to this script so the test runs on any OS and from any directory. | |
| SAMPLE_FILE = Path(__file__).parent / "sample_survey_doc.docx" | |
| # βββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ok(msg: str) -> None: | |
| print(f" β {msg}") | |
| def fail(msg: str) -> None: | |
| print(f" β {msg}") | |
| sys.exit(1) | |
| def poll_report(report_id: str, timeout: int = 60) -> str: | |
| deadline = time.time() + timeout | |
| while time.time() < deadline: | |
| r = requests.get(f"{BASE}/reports/{report_id}/status", headers=HEADERS) | |
| r.raise_for_status() | |
| status = r.json()["status"] | |
| print(f" status={status}") | |
| if status == "complete": | |
| return "complete" | |
| if status == "failed": | |
| fail(f"Report {report_id} failed") | |
| time.sleep(3) | |
| fail(f"Timed out waiting for report {report_id}") | |
| return "timeout" | |
| # βββ Step 1: Notes expander unit check ββββββββββββββββββββββββββββββββββββββββ | |
| def test_notes_expander() -> None: | |
| print("\n=== Step 1: Notes expander (rule-based) ===") | |
| cases = [ | |
| ("semi det nw3 1965ish 95sqm", "semi-detached"), | |
| ("roof bad needs work", "in poor condition"), | |
| ("mains e mains g connected", "electricity"), | |
| ("dg windows ok", "double-glazed"), | |
| ("w/o insulation", "without"), | |
| ("w/ cavity wall insulation", "with"), | |
| ("v bad boiler", "very poor condition"), | |
| ] | |
| for note, expected_fragment in cases: | |
| expanded = expand_notes([note])[0] | |
| if expected_fragment.lower() in expanded.lower(): | |
| ok(f"{note!r} β ...{expected_fragment}... β (full: {expanded!r})") | |
| else: | |
| fail(f"{note!r} β expected {expected_fragment!r} but got {expanded!r}") | |
| # βββ Step 2: Health check βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_health() -> None: | |
| print("\n=== Step 2: Health check ===") | |
| r = requests.get(f"{BASE}/health") | |
| r.raise_for_status() | |
| assert r.json()["status"] == "ok" | |
| ok("GET /health β ok") | |
| # βββ Step 3: Ingest sample doc ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ingest_document() -> str: | |
| print("\n=== Step 3: Test ingest ===") | |
| payload = {"file_path": str(SAMPLE_FILE), "tenant_id": TENANT} | |
| r = requests.post(f"{BASE}/test/ingest", json=payload, headers=JSON_HEADERS) | |
| if r.status_code != 200: | |
| fail(f"POST /test/ingest failed {r.status_code}: {r.text}") | |
| doc_id = r.json()["document_id"] | |
| ok(f"Ingested document_id={doc_id}") | |
| return doc_id | |
| # βββ Step 4: Create report ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_report(doc_id: str) -> str: | |
| print("\n=== Step 4: Create report ===") | |
| r = requests.post(f"{BASE}/reports", headers=HEADERS, params={"document_id": doc_id}) | |
| if r.status_code != 201: | |
| fail(f"POST /reports failed {r.status_code}: {r.text}") | |
| report_id = r.json()["report_id"] | |
| ok(f"Created report_id={report_id}") | |
| return report_id | |
| # βββ Step 5: Generate section (messy notes, ai_level=3 Balanced) ββββββββββββββ | |
| # Uses section D (About the property) β best match for general property description notes. | |
| _TEST_SECTION = "D" | |
| _TEST_BULLETS_D = [ | |
| "semi det nw3 1965ish 95sqm", | |
| "3 bed 2 storey Victorian terrace", | |
| "faces NW front elevation", | |
| "solid brick walls, no cavity", | |
| ] | |
| _TEST_BULLETS_E2 = [ | |
| "roof bad needs work", | |
| "hip roof slate tiles, several slipped", | |
| "flat felt extension roof cracked", | |
| ] | |
| def generate_section(report_id: str, ai_level: int = 3) -> dict: | |
| print(f"\n=== Step 5: Generate section {_TEST_SECTION} (messy notes, ai_level={ai_level}) ===") | |
| payload = { | |
| "template_id": _TEST_SECTION, | |
| "bullets": _TEST_BULLETS_D, | |
| "mode": "generate", | |
| "ai_level": ai_level, | |
| } | |
| r = requests.post( | |
| f"{BASE}/reports/{report_id}/generate", | |
| headers=JSON_HEADERS, | |
| json=payload, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"POST generate failed {r.status_code}: {r.text}") | |
| ok(f"Generation queued (ai_level={ai_level}): {r.json()['status']}") | |
| print(" Pollingβ¦") | |
| poll_report(report_id) | |
| r2 = requests.get(f"{BASE}/reports/{report_id}/sections", headers=HEADERS) | |
| r2.raise_for_status() | |
| sections = r2.json()["sections"] | |
| if _TEST_SECTION not in sections: | |
| fail(f"Section {_TEST_SECTION} not found in response") | |
| section = sections[_TEST_SECTION] | |
| text = section["text"] | |
| mode = section.get("mode", "?") | |
| sp = section.get("style_profile") or {} | |
| ok(f"Section {_TEST_SECTION} generated β mode={mode} ai_level={ai_level} chars={len(text)}") | |
| ok(f"Style profile β tone={sp.get('tone','?')} summary={sp.get('writing_style_summary','?')[:60]}β¦") | |
| print(f"\n ββ GENERATED TEXT (first 400 chars) ββ") | |
| print(f" {text[:400]}") | |
| return sections | |
| # βββ Step 7: Generate at ai_level=1 (RAG Only) ββββββββββββββββββββββββββββββββ | |
| def generate_rag_only(report_id: str) -> None: | |
| print(f"\n=== Step 7: Re-generate section {_TEST_SECTION} at ai_level=1 (RAG Only) ===") | |
| payload = { | |
| "template_id": _TEST_SECTION, | |
| "bullets": _TEST_BULLETS_D[:2], | |
| "mode": "generate", | |
| "ai_level": 1, | |
| "force_regenerate": True, | |
| } | |
| r = requests.post( | |
| f"{BASE}/reports/{report_id}/generate", | |
| headers=JSON_HEADERS, | |
| json=payload, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"POST generate (RAG Only) failed {r.status_code}: {r.text}") | |
| ok("RAG-only generation queued") | |
| print(" Pollingβ¦") | |
| poll_report(report_id) | |
| r2 = requests.get(f"{BASE}/reports/{report_id}/sections", headers=HEADERS) | |
| r2.raise_for_status() | |
| section = r2.json()["sections"].get(_TEST_SECTION, {}) | |
| ok(f"RAG-only section {_TEST_SECTION} returned chars={len(section.get('text',''))}") | |
| # βββ Step 8: Proofread mode βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def proofread_section(report_id: str) -> None: | |
| print(f"\n=== Step 8: Proofread section {_TEST_SECTION} ===") | |
| payload = { | |
| "template_id": _TEST_SECTION, | |
| "bullets": _TEST_BULLETS_D[:2], | |
| "mode": "proofread", | |
| "ai_level": 3, | |
| } | |
| r = requests.post( | |
| f"{BASE}/reports/{report_id}/generate", | |
| headers=JSON_HEADERS, | |
| json=payload, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"POST proofread failed {r.status_code}: {r.text}") | |
| ok("Proofread queued") | |
| print(" Pollingβ¦") | |
| poll_report(report_id) | |
| r2 = requests.get(f"{BASE}/reports/{report_id}/sections", headers=HEADERS) | |
| r2.raise_for_status() | |
| section = r2.json()["sections"].get(_TEST_SECTION, {}) | |
| text = section.get("text", "") | |
| mode = section.get("mode", "?") | |
| ok(f"Section {_TEST_SECTION} after proofread β mode={mode} chars={len(text)}") | |
| print(f"\n ββ PROOFREAD TEXT (first 400 chars) ββ") | |
| print(f" {text[:400]}") | |
| # βββ Step 9: Enhance mode βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def enhance_section(report_id: str) -> None: | |
| print(f"\n=== Step 9: Enhance section {_TEST_SECTION} ===") | |
| payload = { | |
| "template_id": _TEST_SECTION, | |
| "bullets": _TEST_BULLETS_D[:2], | |
| "mode": "enhance", | |
| "ai_level": 4, | |
| } | |
| r = requests.post( | |
| f"{BASE}/reports/{report_id}/generate", | |
| headers=JSON_HEADERS, | |
| json=payload, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"POST enhance failed {r.status_code}: {r.text}") | |
| ok("Enhance queued (ai_level=4)") | |
| print(" Pollingβ¦") | |
| poll_report(report_id) | |
| r2 = requests.get(f"{BASE}/reports/{report_id}/sections", headers=HEADERS) | |
| r2.raise_for_status() | |
| section = r2.json()["sections"].get(_TEST_SECTION, {}) | |
| text = section.get("text", "") | |
| mode = section.get("mode", "?") | |
| ok(f"Section {_TEST_SECTION} after enhance β mode={mode} chars={len(text)}") | |
| print(f"\n ββ ENHANCED TEXT (first 400 chars) ββ") | |
| print(f" {text[:400]}") | |
| # βββ Step 10: Style profile roundtrip βββββββββββββββββββββββββββββββββββββββββ | |
| def test_style_profile_roundtrip(report_id: str) -> None: | |
| print("\n=== Step 10: Style profile roundtrip ===") | |
| r = requests.get(f"{BASE}/reports/{report_id}/sections", headers=HEADERS) | |
| r.raise_for_status() | |
| section = r.json()["sections"].get(_TEST_SECTION, {}) | |
| sp = section.get("style_profile") or {} | |
| required_keys = [ | |
| "tone", "formality_level", "avg_sentence_complexity", | |
| "vocabulary_level", "common_phrases", "structural_patterns", | |
| "writing_style_summary", | |
| ] | |
| missing = [k for k in required_keys if k not in sp] | |
| if missing: | |
| fail(f"style_profile missing keys: {missing}") | |
| ok(f"All 7 style_profile fields present: {list(sp.keys())}") | |
| # βββ Step 11: /extract-notes β valid DOCX βββββββββββββββββββββββββββββββββββββ | |
| def test_extract_notes_docx() -> None: | |
| print("\n=== Step 11: POST /extract-notes β DOCX upload ===") | |
| with open(SAMPLE_FILE, "rb") as fh: | |
| r = requests.post( | |
| f"{BASE}/extract-notes", | |
| headers=HEADERS, | |
| files={"file": ("sample_survey_doc.docx", fh, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"/extract-notes failed {r.status_code}: {r.text}") | |
| data = r.json() | |
| lines = data.get("lines", []) | |
| line_count = data.get("line_count", 0) | |
| if line_count == 0 or not lines: | |
| fail(f"/extract-notes returned no lines: {data}") | |
| ok(f"/extract-notes extracted {line_count} line(s) from DOCX") | |
| print(f" First 3 lines: {lines[:3]}") | |
| # βββ Step 12: /extract-notes β oversized file rejected (413) βββββββββββββββββ | |
| def test_extract_notes_oversized() -> None: | |
| print("\n=== Step 12: POST /extract-notes β oversized file β 413 ===") | |
| # Create an in-memory fake .txt that is exactly 10 MB + 1 byte | |
| big_data = b"x" * (10 * 1024 * 1024 + 1) | |
| r = requests.post( | |
| f"{BASE}/extract-notes", | |
| headers=HEADERS, | |
| files={"file": ("big.txt", io.BytesIO(big_data), "text/plain")}, | |
| ) | |
| if r.status_code != 413: | |
| fail(f"Expected 413 for oversized file but got {r.status_code}: {r.text}") | |
| ok("Oversized notes file correctly rejected with 413") | |
| # βββ Step 13: /upload/batch β batch upload DOCX βββββββββββββββββββββββββββββββ | |
| def test_batch_upload() -> str: | |
| print("\n=== Step 13: POST /upload/batch β batch upload ===") | |
| with open(SAMPLE_FILE, "rb") as fh: | |
| r = requests.post( | |
| f"{BASE}/upload/batch", | |
| headers=HEADERS, | |
| data={"tenant_id": TENANT}, | |
| files=[("files", ("sample_survey_doc.docx", fh, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))], | |
| ) | |
| if r.status_code != 202: | |
| fail(f"POST /upload/batch failed {r.status_code}: {r.text}") | |
| data = r.json() | |
| if data.get("accepted", 0) < 1: | |
| fail(f"Batch upload accepted 0 files: {data}") | |
| doc_id = data["items"][0]["document_id"] | |
| ok(f"Batch upload accepted={data['accepted']} rejected={data['rejected']} doc_id={doc_id}") | |
| return doc_id | |
| # βββ Step 14: /documents/batch-status βββββββββββββββββββββββββββββββββββββββββ | |
| def test_batch_status(doc_id: str) -> None: | |
| print("\n=== Step 14: POST /documents/batch-status ===") | |
| r = requests.post( | |
| f"{BASE}/documents/batch-status", | |
| headers=JSON_HEADERS, | |
| json={"document_ids": [doc_id, "non-existent-id-000"]}, | |
| ) | |
| if r.status_code != 200: | |
| fail(f"/documents/batch-status failed {r.status_code}: {r.text}") | |
| data = r.json() | |
| items = data.get("items", []) | |
| found = [i for i in items if i["document_id"] == doc_id] | |
| not_found = [i for i in items if i["status"] == "not_found"] | |
| if not found: | |
| fail(f"batch-status did not return entry for doc_id={doc_id}") | |
| if not not_found: | |
| fail("batch-status did not return 'not_found' for non-existent ID") | |
| ok(f"batch-status: doc_id status={found[0]['status']} non-existentβnot_found β") | |
| ok(f"Aggregates: pending={data['pending']} processing={data['processing']} complete={data['complete']} failed={data['failed']}") | |
| # βββ Step 15: /documents/tenant-chunk-summary ββββββββββββββββββββββββββββββββ | |
| def test_chunk_summary() -> None: | |
| print("\n=== Step 15: GET /documents/tenant-chunk-summary ===") | |
| r = requests.get(f"{BASE}/documents/tenant-chunk-summary", headers=HEADERS) | |
| if r.status_code != 200: | |
| fail(f"/documents/tenant-chunk-summary failed {r.status_code}: {r.text}") | |
| data = r.json() | |
| if "indexed_chunk_count" not in data: | |
| fail(f"Missing 'indexed_chunk_count' in response: {data}") | |
| ok(f"tenant-chunk-summary: indexed_chunk_count={data['indexed_chunk_count']}") | |
| # βββ main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| print("=" * 65) | |
| print(" RICS Report Genius AI β Full End-to-End Smoke Test") | |
| print("=" * 65) | |
| test_notes_expander() | |
| test_health() | |
| doc_id = ingest_document() | |
| report_id = create_report(doc_id) | |
| generate_section(report_id, ai_level=3) | |
| generate_rag_only(report_id) | |
| proofread_section(report_id) | |
| enhance_section(report_id) | |
| test_style_profile_roundtrip(report_id) | |
| test_extract_notes_docx() | |
| test_extract_notes_oversized() | |
| batch_doc_id = test_batch_upload() | |
| test_batch_status(batch_doc_id) | |
| test_chunk_summary() | |
| print("\n" + "=" * 65) | |
| print(" ALL END-TO-END CHECKS PASSED β") | |
| print("=" * 65) | |
| if __name__ == "__main__": | |
| main() | |