"""E2E: LLM decompose issues → per-issue SP retrieve → generate for one subsection. Bypasses the note-parser heading trap by using the same per-section observations as ``test_sp_decompose_notes.py``. Does not require ``STANDARD_PARAGRAPHS_DECOMPOSE_NOTES=true`` (uses force_decompose). Examples: python scripts/test_sp_decompose_generate.py --section D1 --force-llm python scripts/test_sp_decompose_generate.py --section F6 --force-llm --all-sps """ from __future__ import annotations import argparse import importlib.util import json import sys from datetime import datetime, timezone from pathlib import Path _ROOT = Path(__file__).resolve().parents[1] if str(_ROOT) not in sys.path: sys.path.insert(0, str(_ROOT)) from backend.config import settings from backend.domain import template_discoverer from backend.standard_paragraphs.generate import generate_from_standard_paragraphs from backend.standard_paragraphs.note_issues_manifest import record_note_issues from backend.storage import retrieval_manifest def _load_decompose_cases() -> tuple[list[dict], str]: path = _ROOT / "scripts" / "test_sp_decompose_notes.py" spec = importlib.util.spec_from_file_location("test_sp_decompose_notes", path) assert spec and spec.loader mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return list(mod.CASES), str(mod.TENANT) CASES, DEFAULT_TENANT = _load_decompose_cases() def _case_for(section_id: str) -> dict: sid = section_id.strip().upper() for case in CASES: if case["section_id"].upper() == sid: return case known = [c["section_id"] for c in CASES] raise SystemExit(f"Unknown section {section_id!r}. Known: {known}") def main() -> int: parser = argparse.ArgumentParser( description="E2E SP: decompose → retrieve → generate (one subsection)" ) parser.add_argument("--section", required=True, help="e.g. D1, D8, F6") parser.add_argument( "--force-llm", action="store_true", help="Force LLM decompose even for short notes", ) parser.add_argument( "--all-sps", action="store_true", help=( "Pass EVERY standard paragraph for this subsection from FAISS " "(no Top-K). Still decomposes notes into findings for the prompt." ), ) parser.add_argument( "--style-samples", action="store_true", help=( "Force-inject past REFERENCE subsection samples into the SP prompt " "(overrides STANDARD_PARAGRAPHS_STYLE_SAMPLES_ENABLED for this run)." ), ) parser.add_argument("--tenant", default=DEFAULT_TENANT) parser.add_argument( "--draft-id", default="", help="Retrieval manifest id (default: sp-e2e-
-)", ) args = parser.parse_args() case = _case_for(args.section) sid = case["section_id"] title = case["section_title"] observations = list(case["observations"]) suffix = "allsps" if args.all_sps else "e2e" draft_id = args.draft_id.strip() or ( f"sp-{suffix}-{sid.lower()}-" + datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") ) print("tenant=", args.tenant) print("draft=", draft_id) print("section=", sid, title) print("force_decompose=True force_llm=", args.force_llm) print("use_all_section_sps=", args.all_sps) print("style_samples=", args.style_samples) print("decompose_flag_in_env=", settings.standard_paragraphs_decompose_notes) print("observations:") for o in observations: print(" -", o) schema = template_discoverer.ensure_canonical_schema(args.tenant) text, hits, messages, guidance, issues, llm_usage, style_sample_count = ( generate_from_standard_paragraphs( tenant_id=args.tenant, schema=schema, section_id=sid, section_title=title, observations=observations, candidate_ids=[sid], force_decompose=True, force_decompose_llm=args.force_llm, use_all_section_sps=args.all_sps, style_samples_enabled=True if args.style_samples else None, ) ) issues_path = record_note_issues( args.tenant, draft_id, section_id=sid, section_title=title, observations=observations, issues=list(issues), source="e2e_decompose_generate", used_llm=True if args.force_llm else None, ) retrieval_manifest.record_section_retrieval( args.tenant, draft_id, section_id=sid, section_title=title, observations=observations, baseline_text=guidance, hits=hits, status="MAPPED" if text.strip() and hits else "NO_RAG_MATCH", prompt_messages=messages, retrieved_count=len(hits), prompt_chunk_count=len(hits), knowledge_source="standard_paragraph", generated_text=text, retrieval_issues=list(issues), requested_top_k=len(hits) if args.all_sps else None, llm_usage=llm_usage, style_sample_count=style_sample_count, ) ret_path = retrieval_manifest.retrieval_manifest_path(args.tenant, draft_id) print("\n=== ISSUES (LLM / heuristic) ===") for i, issue in enumerate(issues, 1): print(f" {i}. {issue}") print("note_issues=", issues_path) # Show how the user prompt was grouped (findings ↔ candidates). user_prompt = "" if messages: for msg in messages: if msg.get("role") == "user": user_prompt = msg.get("content") or "" break if "CURRENT INSPECTION FINDINGS" in user_prompt or "CURRENT FINDINGS" in user_prompt: print("\n=== PROMPT FINDINGS / CANDIDATES (excerpt) ===") # Print from findings header through first ~2500 chars of that block. start = user_prompt.find("CURRENT INSPECTION FINDINGS") if start < 0: start = user_prompt.find("CURRENT FINDINGS") excerpt = user_prompt[start : start + 2500] print(excerpt) if len(user_prompt) - start > 2500: print("... [truncated]") print("\n=== RETRIEVED SPs (flat manifest hits) ===", len(hits), ("(ALL section SPs)" if args.all_sps else "")) for i, h in enumerate(hits[:20], 1): preview = (h.text or "").replace("\n", " ")[:120] if args.all_sps: print(f" {i}. idx={h.paragraph_index} {preview!r}") else: print(f" {i}. cosine={getattr(h, 'score', 0) or 0:.3f} fusion={getattr(h, 'fusion_score', 0) or 0:.4f} {preview!r}") if len(hits) > 20: print(f" ... +{len(hits) - 20} more") print("\n=== GENERATED ===") print("style_sample_count=", style_sample_count) print("llm_usage=", llm_usage) print(text or "(empty)") print("\nretrieval=", ret_path) sample = ( _ROOT / "backend" / "standard_paragraphs" / "samples" / f"{draft_id}.e2e.json" ) payload = { "draft_id": draft_id, "section_id": sid, "section_title": title, "observations": observations, "retrieval_issues": issues, "retrieved_chunk_count": len(hits), "use_all_section_sps": args.all_sps, "generated_text": text, "note_issues_path": str(issues_path), "retrieval_path": str(ret_path), } sample.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") print("sample copy=", sample) return 0 if text.strip() else 2 if __name__ == "__main__": raise SystemExit(main())