File size: 35,244 Bytes
ce8f04a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
"""
Document quality loop: holistic/panel findings → targeted multi-section rewrite.

Replaces the old nuclear strategy (wipe all sections and regenerate from zero)
with keep-good / rewrite-weak passes for higher pass rates before PDF/DOCX export.
"""
from __future__ import annotations

import difflib
import logging
import os
import re
from typing import Any, Dict, List, Optional, Sequence, Tuple

logger = logging.getLogger(__name__)

DEFAULT_MIN_KEEP_SCORE = int(os.environ.get("QUALITY_LOOP_MIN_SECTION_CHARS", "120"))
MAX_SECTIONS_PER_PASS = int(os.environ.get("QUALITY_LOOP_MAX_SECTIONS_PER_PASS", "8"))


def _norm(s: str) -> str:
    return re.sub(r"\s+", " ", (s or "").lower().strip())


def section_title_match(label: str, candidates: Sequence[str], threshold: float = 0.45) -> Optional[str]:
    """Fuzzy-map free-text section label onto plan titles."""
    if not label or not candidates:
        return None
    nl = _norm(label)
    if nl in ("ogólne", "ogolne", "całość", "calosc", "all", "general", "wniosek", "dokument"):
        return None
    best: Tuple[float, Optional[str]] = (0.0, None)
    for c in candidates:
        nc = _norm(c)
        if not nc:
            continue
        if nl in nc or nc in nl:
            return c
        r = difflib.SequenceMatcher(None, nl, nc).ratio()
        if r > best[0]:
            best = (r, c)
    return best[1] if best[0] >= threshold else None


def extract_section_targets_from_holistic(
    report: Any,
    plan_titles: Sequence[str],
) -> Dict[str, List[str]]:
    """
    Build map title -> list of fix instructions from holistic report.
    """
    targets: Dict[str, List[str]] = {t: [] for t in plan_titles}
    global_notes: List[str] = []

    recs = []
    if hasattr(report, "key_recommendations"):
        recs = list(report.key_recommendations or [])
    elif isinstance(report, dict):
        recs = list(report.get("key_recommendations") or [])

    for rec in recs:
        rec_s = str(rec).strip()
        if not rec_s:
            continue
        matched = section_title_match(rec_s, plan_titles)
        # Try to find section name mentioned in recommendation
        if not matched:
            for t in plan_titles:
                if _norm(t) in _norm(rec_s):
                    matched = t
                    break
        if matched:
            targets.setdefault(matched, []).append(rec_s)
        else:
            global_notes.append(rec_s)

    # Category feedback → map to typical sections
    cat_map = {
        "budget_consistency": ("budżet", "harmonogram", "koszt", "finans"),
        "logical_flow": ("streszczenie", "opis", "cel", "logika"),
        "program_alignment": ("innowacyj", "uzasadn", "dopasow", "program"),
        "dnsh_assessment": ("środowisk", "dnsh", "zrównoważ", "klimat"),
    }
    for cat_name, keywords in cat_map.items():
        cat = getattr(report, cat_name, None) if not isinstance(report, dict) else report.get(cat_name)
        feedback = ""
        flags: List[str] = []
        score = 100
        if cat is not None:
            if hasattr(cat, "feedback"):
                feedback = cat.feedback or ""
                flags = list(getattr(cat, "inconsistencies_flagged", None) or [])
                score = int(getattr(cat, "score", 100) or 100)
            elif isinstance(cat, dict):
                feedback = cat.get("feedback") or ""
                flags = list(cat.get("inconsistencies_flagged") or [])
                score = int(cat.get("score", 100) or 100)
        if score >= 70 and not flags:
            continue
        note = feedback
        if flags:
            note = (note + " " if note else "") + "; ".join(flags[:5])
        if not note:
            continue
        hit = False
        for t in plan_titles:
            nt = _norm(t)
            if any(k in nt for k in keywords):
                targets.setdefault(t, []).append(f"[{cat_name}] {note}")
                hit = True
        if not hit:
            global_notes.append(f"[{cat_name}] {note}")

    # Distribute global notes to weakest sections (shortest content heuristic later)
    if global_notes:
        for t in plan_titles:
            targets.setdefault(t, []).extend(global_notes[:3])

    # Drop empty
    return {k: v for k, v in targets.items() if v}


def extract_section_targets_from_panel_issues(
    issues: Sequence[Any],
    plan_titles: Sequence[str],
) -> Dict[str, List[str]]:
    targets: Dict[str, List[str]] = {}
    for issue in issues or []:
        if isinstance(issue, dict):
            affected = str(issue.get("affected_section") or "Ogólne")
            msg = str(issue.get("message") or "")
            rec = str(issue.get("recommendation") or "")
            sev = str(issue.get("severity") or "?")
            line = f"[{sev}] {msg}" + (f" | Rek: {rec}" if rec else "")
        else:
            affected = str(getattr(issue, "affected_section", "Ogólne") or "Ogólne")
            msg = str(getattr(issue, "message", "") or "")
            rec = str(getattr(issue, "recommendation", "") or "")
            sev = str(getattr(issue, "severity", "?") or "?")
            line = f"[{sev}] {msg}" + (f" | Rek: {rec}" if rec else "")
        matched = section_title_match(affected, plan_titles)
        if not matched:
            # general → all titles get a light note (limited later)
            for t in plan_titles:
                targets.setdefault(t, []).append(line)
        else:
            targets.setdefault(matched, []).append(line)
    return targets


def extract_section_targets_from_compliance_checklist(
    checklist: Any,
    plan_titles: Sequence[str],
) -> Dict[str, List[str]]:
    """Map compliance_checklist.missing_sections onto plan titles (P0 priority)."""
    targets: Dict[str, List[str]] = {}
    if not checklist:
        return targets
    if isinstance(checklist, dict):
        missing = list(checklist.get("missing_sections") or [])
        coverage = checklist.get("coverage_score")
    else:
        missing = list(getattr(checklist, "missing_sections", None) or [])
        coverage = getattr(checklist, "coverage_score", None)
    for raw in missing:
        label = str(raw or "").strip()
        if not label:
            continue
        # Strip " (pusta treść)" suffix used by regulation_checklist
        clean = re.sub(r"\s*\(pusta tre[sś][cć]\)\s*$", "", label, flags=re.I).strip()
        matched = section_title_match(clean, plan_titles) or section_title_match(label, plan_titles)
        note = (
            f"[compliance_checklist] Brakuje wymaganej sekcji regulaminu: {label}. "
            f"Uzupełnij merytorykę i jawne odniesienie do regulaminu programu."
        )
        if coverage is not None:
            note += f" (coverage={coverage}%)"
        if matched:
            targets.setdefault(matched, []).append(note)
        else:
            # Unmapped required section → push to first plan title + global note on all short titles
            for t in plan_titles:
                targets.setdefault(t, []).append(note)
                break
    return targets


def extract_section_targets_from_citation_failures(
    citation_data: Any,
    plan_titles: Sequence[str],
    *,
    min_score: float = 0.72,
) -> Dict[str, List[str]]:
    """Build rewrite targets from citation / faithfulness failures (P0 priority)."""
    targets: Dict[str, List[str]] = {}
    if not citation_data:
        return targets

    # Forms: list of {section, overall_score, issues, recommendation}
    # or dict section->payload, or flat {overall_score, issues, section}
    items: List[Dict[str, Any]] = []
    if isinstance(citation_data, list):
        items = [c for c in citation_data if isinstance(c, dict)]
    elif isinstance(citation_data, dict):
        if any(k in citation_data for k in ("overall_score", "overall_citation_score", "issues", "section")):
            items = [citation_data]
        else:
            for sec, payload in citation_data.items():
                if isinstance(payload, dict):
                    items.append({**payload, "section": payload.get("section") or sec})
                elif isinstance(payload, (int, float)):
                    items.append({"section": sec, "overall_score": float(payload)})

    for item in items:
        score = item.get("overall_score")
        if score is None:
            score = item.get("overall_citation_score")
        try:
            score_f = float(score) if score is not None else None
        except (TypeError, ValueError):
            score_f = None
        issues = item.get("issues") or []
        quality = str(item.get("quality") or item.get("citation_quality") or "")
        weak = (
            (score_f is not None and score_f < min_score)
            or quality.lower() in ("poor", "low", "weak", "fail", "failed")
            or bool(issues)
        )
        if not weak:
            continue
        section = str(item.get("section") or item.get("affected_section") or "Ogólne")
        rec = str(item.get("recommendation") or "").strip()
        issue_bits: List[str] = []
        for iss in (issues if isinstance(issues, list) else [issues])[:4]:
            if isinstance(iss, list):
                issue_bits.extend(str(x) for x in iss[:2] if x)
            elif iss:
                issue_bits.append(str(iss))
        score_txt = f"{score_f:.2f}" if score_f is not None else "?"
        line = (
            f"[citation_failure] Ugruntowanie cytowaniami zbyt niskie (score={score_txt}"
            f"{', quality=' + quality if quality else ''}). "
            f"Dodaj twarde odniesienia do regulaminu/snapshotu i usuń nieugruntowane twierdzenia."
        )
        if issue_bits:
            line += " Problemy: " + "; ".join(issue_bits[:3])
        if rec:
            line += f" | Rek: {rec}"
        matched = section_title_match(section, plan_titles)
        if matched:
            targets.setdefault(matched, []).append(line)
        else:
            for t in plan_titles:
                targets.setdefault(t, []).append(line)
    return targets


def extract_section_targets_from_trap_issues(
    trap_data: Any,
    plan_titles: Sequence[str],
) -> Dict[str, List[str]]:
    """Build rewrite targets from Kruczkowski trap detections (P0 priority)."""
    targets: Dict[str, List[str]] = {}
    if not trap_data:
        return targets

    traps: List[Any] = []
    if isinstance(trap_data, list):
        traps = list(trap_data)
    elif isinstance(trap_data, dict):
        if trap_data.get("detected") is not None:
            traps = list(trap_data.get("detected") or [])
            # Also honor high/critical risk as a doc-level signal when no per-trap list
            risk = str(trap_data.get("risk_level") or trap_data.get("trap_risk") or "").lower()
            if not traps and risk in ("high", "critical"):
                traps = [{"type": "trap_risk", "message": f"trap_risk={risk}", "severity": risk}]
        elif trap_data.get("traps") is not None:
            traps = list(trap_data.get("traps") or [])
        else:
            # section -> payload map
            for sec, payload in trap_data.items():
                if isinstance(payload, dict):
                    detected = payload.get("detected") or payload.get("traps") or []
                    if isinstance(detected, list):
                        for d in detected:
                            if isinstance(d, dict):
                                traps.append({**d, "section": d.get("section") or sec})
                            else:
                                traps.append({"message": str(d), "section": sec})
                    elif payload.get("risk_level") in ("high", "critical", "medium"):
                        traps.append({
                            "section": sec,
                            "severity": payload.get("risk_level"),
                            "message": f"trap_risk={payload.get('risk_level')}",
                        })
                elif isinstance(payload, list):
                    for d in payload:
                        traps.append(d if isinstance(d, dict) else {"message": str(d), "section": sec})

    for trap in traps:
        if isinstance(trap, dict):
            section = str(trap.get("section") or trap.get("affected_section") or "Ogólne")
            ttype = str(trap.get("type") or trap.get("trap_type") or trap.get("category") or "trap")
            msg = str(trap.get("message") or trap.get("description") or trap.get("detail") or ttype)
            sev = str(trap.get("severity") or trap.get("risk_level") or "medium")
            rec = str(trap.get("recommendation") or "").strip()
        else:
            section = "Ogólne"
            ttype = "trap"
            msg = str(trap)
            sev = "medium"
            rec = ""
        line = (
            f"[trap:{ttype}|{sev}] {msg}. "
            f"Usuń niekwalifikowalne koszty/pułapki regulaminowe i jawnie wyklucz ryzyko."
        )
        if rec:
            line += f" | Rek: {rec}"
        matched = section_title_match(section, plan_titles)
        if matched:
            targets.setdefault(matched, []).append(line)
        else:
            for t in plan_titles:
                targets.setdefault(t, []).append(line)
    return targets


def _merge_target_maps(*maps: Dict[str, List[str]]) -> Dict[str, List[str]]:
    """Merge title->notes maps preserving order (first maps win priority position)."""
    out: Dict[str, List[str]] = {}
    for m in maps:
        if not m:
            continue
        for title, notes in m.items():
            bucket = out.setdefault(title, [])
            for n in notes or []:
                if n and n not in bucket:
                    bucket.append(n)
    return out


def build_priority_rewrite_targets(
    plan_titles: Sequence[str],
    *,
    compliance_checklist: Any = None,
    citation_failures: Any = None,
    trap_issues: Any = None,
    report: Any = None,
    panel_issues: Optional[Sequence[Any]] = None,
    source: str = "holistic",
    advisor_report: Any = None,
) -> Dict[str, List[str]]:
    """
    P0 target order: advisor brief gaps → compliance → citation → traps → holistic/panel.
    Compliance/citation/trap notes are placed first so pick_sections_to_fix prioritizes them.
    """
    advisor_targets: Dict[str, List[str]] = {}
    if advisor_report is not None:
        try:
            from agents.world_class_advisor import advisor_findings_to_rewrite_targets

            advisor_targets = advisor_findings_to_rewrite_targets(advisor_report, plan_titles)
        except Exception as e:
            logger.debug("[QualityLoop] advisor targets skipped: %s", e)

    compliance_targets = extract_section_targets_from_compliance_checklist(
        compliance_checklist, plan_titles
    )
    citation_targets = extract_section_targets_from_citation_failures(
        citation_failures, plan_titles
    )
    trap_targets = extract_section_targets_from_trap_issues(trap_issues, plan_titles)

    secondary: Dict[str, List[str]] = {}
    if source == "holistic" and report is not None:
        secondary = extract_section_targets_from_holistic(report, plan_titles)
    elif panel_issues is not None:
        secondary = extract_section_targets_from_panel_issues(panel_issues, plan_titles)
    elif source == "panel" and report is not None:
        # tolerate report-as-issues misuse
        secondary = extract_section_targets_from_panel_issues(
            getattr(report, "issues", None) or [], plan_titles
        )

    return _merge_target_maps(
        advisor_targets,
        compliance_targets,
        citation_targets,
        trap_targets,
        secondary,
    )


def collect_grounding_signals_from_state(state: Dict[str, Any]) -> Dict[str, Any]:
    """Harvest checklist / citation / trap signals from generator state + external_context."""
    ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}
    checklist = (
        state.get("compliance_checklist")
        or ext.get("compliance_checklist")
        or {}
    )
    # Citations: prefer explicit state keys, else aggregate v5_verification from traceability
    citations = state.get("citation_failures") or state.get("citation_scores") or ext.get("citation_failures")
    traps = state.get("trap_issues") or ext.get("trap_issues") or ext.get("v5_grounding_certificate")

    if citations is None or traps is None:
        trace = state.get("traceability_data") or {}
        cit_list: List[Dict[str, Any]] = []
        trap_map: Dict[str, Any] = {}
        for section_key, events in (trace.items() if isinstance(trace, dict) else []):
            if not isinstance(events, list):
                continue
            for ev in events:
                if not isinstance(ev, dict):
                    continue
                if ev.get("type") != "v5_verification":
                    continue
                data = ev.get("data") or {}
                if not isinstance(data, dict):
                    continue
                sec_name = data.get("section") or section_key
                cit = data.get("citation") or {}
                if citations is None and isinstance(cit, dict):
                    cit_list.append({
                        "section": sec_name,
                        "overall_score": cit.get("overall_score"),
                        "quality": cit.get("quality"),
                        "issues": cit.get("issues") or [],
                        "recommendation": cit.get("recommendation") or "",
                    })
                tr = data.get("traps") or {}
                if traps is None and isinstance(tr, dict):
                    trap_map[str(sec_name)] = tr
        if citations is None and cit_list:
            citations = cit_list
        if traps is None and trap_map:
            traps = trap_map

    # Certificate-level trap signal
    if traps is None:
        v5c = ext.get("v5_grounding_certificate") or {}
        if isinstance(v5c, dict) and (v5c.get("trap_risk") or v5c.get("detected")):
            traps = v5c

    return {
        "compliance_checklist": checklist,
        "citation_failures": citations,
        "trap_issues": traps,
    }


def pick_sections_to_fix(
    plan: Sequence[Any],
    generated: Dict[str, str],
    targets: Dict[str, List[str]],
    *,
    max_sections: int = MAX_SECTIONS_PER_PASS,
) -> List[str]:
    """Prefer targeted weak sections; if none, pick shortest / incomplete ones."""
    titles = []
    for s in plan:
        t = s.get("title") if isinstance(s, dict) else str(s)
        if t:
            titles.append(t)

    ranked: List[Tuple[int, str]] = []
    for t in titles:
        notes = targets.get(t) or []
        content = (generated or {}).get(t) or ""
        incomplete = 1 if ("[UZUPEŁNIĆ" in content or "[DO WERYFIKACJI" in content or len(content) < DEFAULT_MIN_KEEP_SCORE) else 0
        priority = len(notes) * 10 + incomplete * 5 + max(0, 500 - len(content)) // 50
        if notes or incomplete:
            ranked.append((priority, t))
    ranked.sort(key=lambda x: x[0], reverse=True)
    chosen = [t for _, t in ranked[:max_sections]]
    if not chosen and titles:
        # Always fix at least top-N shortest when critic failed without mapping
        by_len = sorted(titles, key=lambda t: len((generated or {}).get(t) or ""))
        chosen = by_len[: min(3, max_sections)]
    return chosen


def rewrite_section_with_feedback(
    *,
    title: str,
    section_type: str,
    current_content: str,
    instructions: List[str],
    program_name: str,
    project_description: str = "",
    company_context: str = "",
    external_context: Optional[dict] = None,
    regulation_boost: str = "",
) -> str:
    """LLM rewrite of one section with critic/audit instructions + regulation grounding."""
    from agents.helpers import generate_section_light
    from core.llm_router import get_llm
    from langchain_core.messages import HumanMessage

    instr = "\n".join(f"- {i}" for i in (instructions or [])[:12])
    if not instr:
        instr = "- Podnieś merytorykę, spójność z resztą wniosku i zgodność z programem."

    reg_block = (regulation_boost or "").strip()
    if reg_block:
        reg_block = reg_block[:6000]
        reg_section = f"""
Kontekst regulaminowy (ŹRÓDŁO PRAWDY — cytuj reguły, nie wymyślaj):
--------------------
{reg_block}
--------------------
Wzmocnij ugruntowanie: jawne odniesienia do regulaminu/snapshotu, zero niekwalifikowalnych kosztów.
"""
    else:
        reg_section = (
            "\nBrak snapshotu regulaminu w kontekście — unikaj kategorycznych twierdzeń o "
            "kwalifikowalności; oznacz niepewne miejsca [DO WERYFIKACJI: regulamin].\n"
        )

    # Prefer focused rewrite when content exists
    if current_content and len(current_content.strip()) > 80:
        llm = get_llm(task_type="writing")
        prompt = f"""Jesteś redaktorem wniosków unijnych. PRZEPISZ sekcję „{title}" tak, aby usunąć wskazane wady.
Zachowaj język polski, styl urzędowy, Markdown. NIE wymyślaj faktów spoza kontekstu.
Dla braków danych użyj [DO WERYFIKACJI: …] — nie zostawiaj pustych miejsc.
Priorytet: checklist regulaminu, cytowania/ugruntowanie, pułapki Kruczkowskiego.

Program: {program_name}
Opis projektu (skrót):
{(project_description or '')[:2500]}

Dane firmy / kontekst:
{(company_context or '')[:2000]}
{reg_section}
Wady / instrukcje poprawy:
{instr}

Obecna treść sekcji:
--------------------
{current_content[:12000]}
--------------------

Zwróć WYŁĄCZNIE poprawioną treść sekcji (bez preambuły)."""
        try:
            resp = llm.invoke([HumanMessage(content=prompt)])
            content = resp.content if hasattr(resp, "content") else str(resp)
            if content and len(content.strip()) > 40:
                return content.strip()
        except Exception as e:
            logger.warning("[QualityLoop] rewrite failed for %s: %s", title, e)

    # Fallback: generate_section_light with feedback + regulation in context
    ctx_parts = [project_description or ""]
    if reg_block:
        ctx_parts.append(reg_block)
    ctx_parts.append(f"INSTRUKCJE POPRAWY SEKCJI:\n{instr}")
    ctx_parts.append(f"Poprzednia treść (do ulepszenia):\n{(current_content or '')[:4000]}")
    ctx = "\n\n".join(p for p in ctx_parts if p)
    return generate_section_light(
        section_type=section_type or title,
        context=ctx,
        external_context=external_context or {},
        program_name=program_name,
        light_mode=False,
    )


def run_quality_expectation_step(
    state: Dict[str, Any],
    *,
    source: str = "holistic",
    report: Any = None,
    issues: Optional[Sequence[Any]] = None,
    regulation_boost: str = "",
    min_score: int = 70,
) -> Dict[str, Any]:
    """
    One production expectation step: advisor evaluate → if not regulation-ready,
    apply targeted fixes → re-evaluate. Used by generator quality path.

    Returns dict with advisor_before/after, expectation ready flags, fixed state.
    """
    from agents.world_class_advisor import evaluate_from_generator_state
    from core.generation.expectation_loop import (
        extract_blockers,
        is_regulation_ready,
        report_to_dict,
    )

    before = evaluate_from_generator_state(state)
    ready = is_regulation_ready(before, min_score=min_score)
    out: Dict[str, Any] = {
        "advisor_before": report_to_dict(before),
        "regulation_ready": ready,
        "remaining_blockers": extract_blockers(before),
        "fixed_sections": [],
        "generated_sections": dict(state.get("generated_sections") or {}),
    }
    if ready:
        out["stop_reason"] = "ready"
        out["advisor_after"] = out["advisor_before"]
        return out

    fixed = apply_targeted_section_fixes(
        state,
        source=source,
        report=report,
        issues=issues,
        regulation_boost=regulation_boost,
        advisor_report=before,
    )
    new_state = {**state, "generated_sections": fixed.get("generated_sections") or state.get("generated_sections")}
    after = evaluate_from_generator_state(new_state)
    out["advisor_after"] = report_to_dict(after)
    out["regulation_ready"] = is_regulation_ready(after, min_score=min_score)
    out["remaining_blockers"] = extract_blockers(after)
    out["fixed_sections"] = list(fixed.get("fixed_sections") or [])
    out["generated_sections"] = fixed.get("generated_sections") or out["generated_sections"]
    out["targets"] = fixed.get("targets") or {}
    out["stop_reason"] = "ready" if out["regulation_ready"] else "needs_retry"
    return out


def apply_targeted_section_fixes(
    state: Dict[str, Any],
    *,
    source: str,
    report: Any = None,
    issues: Optional[Sequence[Any]] = None,
    regulation_boost: str = "",
    advisor_report: Any = None,
) -> Dict[str, Any]:
    """
    Returns updated generated_sections (partial rewrite) + metadata for telemetry.

    Rewrite targets are built in P0 order:
      world-class advisor → compliance → citation → traps → holistic/panel.
    regulation_boost (if provided or buildable) is injected into each section rewrite.
    """
    plan = state.get("sections_plan") or []
    generated = dict(state.get("generated_sections") or {})
    titles = []
    type_by_title: Dict[str, str] = {}
    for s in plan:
        if isinstance(s, dict):
            t = s.get("title") or s.get("type") or ""
            titles.append(t)
            type_by_title[t] = s.get("type") or t
        else:
            titles.append(str(s))
            type_by_title[str(s)] = str(s)

    signals = collect_grounding_signals_from_state(state)
    if advisor_report is None:
        # Only auto-run world-class advisor when regulation brief signals exist —
        # avoid rewriting all sections solely for empty-brief noise.
        ext0 = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}
        has_brief_signals = bool(
            ext0.get("advisor_brief")
            or ext0.get("required_sections")
            or ext0.get("regulation_key_rules")
            or ext0.get("key_rules")
            or ext0.get("attention_points")
        )
        if has_brief_signals:
            try:
                from agents.world_class_advisor import evaluate_from_generator_state

                advisor_report = evaluate_from_generator_state(state)
            except Exception as e:
                logger.debug("[QualityLoop] advisor evaluate skipped: %s", e)
                advisor_report = None

    targets = build_priority_rewrite_targets(
        titles,
        compliance_checklist=signals.get("compliance_checklist"),
        citation_failures=signals.get("citation_failures"),
        trap_issues=signals.get("trap_issues"),
        report=report,
        panel_issues=issues,
        source=source,
        advisor_report=advisor_report,
    )

    to_fix = pick_sections_to_fix(plan, generated, targets)
    program = state.get("document_type") or "wniosek dotacyjny"
    project_desc = state.get("project_description") or ""
    company_ctx = state.get("additional_context") or ""
    ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}

    boost = (regulation_boost or state.get("regulation_boost") or "").strip()
    if not boost:
        # Lightweight inline boost from external_context (caller may pass full agent boost)
        snap_id = ext.get("regulation_snapshot_id")
        rules = ext.get("regulation_key_rules") or ext.get("key_rules") or []
        if rules:
            boost = (
                "[REGULATION SNAPSHOT v5.0 - QUALITY LOOP]:\n"
                + "\n".join(f"- {r}" for r in list(rules)[:8])
            )
        elif snap_id:
            boost = f"[REGULATION SNAPSHOT v5.0 - id={snap_id}]\nUżyj reguł z przypisanego snapshotu regulaminu."
        elif ext.get("required_sections"):
            boost = (
                "[REGULATION CONTEXT - required_sections]:\n"
                + "\n".join(f"- Wymagana sekcja: {s}" for s in list(ext.get("required_sections") or [])[:12])
            )

    fixed: List[str] = []
    for title in to_fix:
        notes = targets.get(title) or ["Podnieś jakość i spójność z całym wnioskiem."]
        new_text = rewrite_section_with_feedback(
            title=title,
            section_type=type_by_title.get(title, title),
            current_content=generated.get(title) or "",
            instructions=notes,
            program_name=program,
            project_description=project_desc,
            company_context=company_ctx,
            external_context=ext,
            regulation_boost=boost,
        )
        if new_text and len(new_text.strip()) > 40:
            generated[title] = new_text.strip()
            fixed.append(title)
            logger.info("[QualityLoop] rewritten section '%s' (%s notes)", title, len(notes))

    return {
        "generated_sections": generated,
        "fixed_sections": fixed,
        "targets": {k: v[:5] for k, v in targets.items() if k in to_fix},
        "source": source,
        "regulation_boost_used": bool(boost),
        "priority_signals": {
            "checklist_missing": len(
                (signals.get("compliance_checklist") or {}).get("missing_sections") or []
            )
            if isinstance(signals.get("compliance_checklist"), dict)
            else 0,
            "citation_items": len(signals.get("citation_failures") or [])
            if isinstance(signals.get("citation_failures"), list)
            else (1 if signals.get("citation_failures") else 0),
            "trap_items": (
                len((signals.get("trap_issues") or {}).get("detected") or [])
                if isinstance(signals.get("trap_issues"), dict)
                else len(signals.get("trap_issues") or [])
                if isinstance(signals.get("trap_issues"), list)
                else 0
            ),
        },
    }


def score_document_readiness(
    generated: Dict[str, str],
    plan: Sequence[Any],
    *,
    compliance_checklist: Any = None,
    citation_scores: Any = None,
    trap_issues: Any = None,
    regulation_context_present: Optional[bool] = None,
    external_context: Optional[dict] = None,
) -> Dict[str, Any]:
    """Heuristic readiness 0-100 for export soft-gate decisions, with grounding fields."""
    incomplete = []
    ok = 0
    for s in plan or []:
        t = s.get("title") if isinstance(s, dict) else str(s)
        c = (generated or {}).get(t) or ""
        if len(c) < DEFAULT_MIN_KEEP_SCORE or "[UZUPEŁNIĆ" in c:
            incomplete.append(t)
        else:
            ok += 1
    total = len(plan or [])
    score = int(round(100 * ok / max(total, 1))) if total else 0

    ext = external_context if isinstance(external_context, dict) else {}
    checklist = compliance_checklist if compliance_checklist is not None else ext.get("compliance_checklist")
    coverage: Optional[int] = None
    missing_sections: List[str] = []
    if isinstance(checklist, dict):
        if checklist.get("coverage_score") is not None:
            try:
                coverage = int(checklist.get("coverage_score"))
            except (TypeError, ValueError):
                coverage = None
        missing_sections = list(checklist.get("missing_sections") or [])
    elif checklist is not None:
        coverage = getattr(checklist, "coverage_score", None)
        missing_sections = list(getattr(checklist, "missing_sections", None) or [])

    # Aggregate citation mean
    cit_raw = citation_scores if citation_scores is not None else ext.get("citation_failures")
    citation_values: List[float] = []
    if isinstance(cit_raw, list):
        for item in cit_raw:
            if isinstance(item, dict):
                sc = item.get("overall_score", item.get("overall_citation_score"))
                if sc is not None:
                    try:
                        citation_values.append(float(sc))
                    except (TypeError, ValueError):
                        pass
            elif isinstance(item, (int, float)):
                citation_values.append(float(item))
    elif isinstance(cit_raw, dict):
        if "overall_score" in cit_raw or "overall_citation_score" in cit_raw:
            sc = cit_raw.get("overall_score", cit_raw.get("overall_citation_score"))
            try:
                citation_values.append(float(sc))
            except (TypeError, ValueError):
                pass
        else:
            for v in cit_raw.values():
                if isinstance(v, dict):
                    sc = v.get("overall_score", v.get("overall_citation_score"))
                    if sc is not None:
                        try:
                            citation_values.append(float(sc))
                        except (TypeError, ValueError):
                            pass
                elif isinstance(v, (int, float)):
                    citation_values.append(float(v))
    citation_mean = (
        round(sum(citation_values) / len(citation_values), 3) if citation_values else None
    )
    citation_ok = citation_mean is not None and citation_mean >= 0.72

    traps = trap_issues if trap_issues is not None else (
        ext.get("trap_issues") or ext.get("v5_grounding_certificate")
    )
    trap_risk = "unknown"
    trap_count = 0
    if isinstance(traps, dict):
        trap_risk = str(traps.get("risk_level") or traps.get("trap_risk") or "unknown")
        detected = traps.get("detected") or traps.get("traps") or []
        trap_count = len(detected) if isinstance(detected, list) else 0
    elif isinstance(traps, list):
        trap_count = len(traps)
        trap_risk = "medium" if trap_count else "low"

    if regulation_context_present is None:
        # Infer from external_context / generated content markers
        if ext.get("regulation_snapshot_id") or ext.get("required_sections"):
            regulation_context_present = True
        else:
            blob = " ".join((generated or {}).values())[:8000]
            regulation_context_present = (
                "[REGULATION SNAPSHOT" in blob
                or "Kontekst regulaminowy" in blob
                or bool(ext.get("v5_grounding_certificate"))
            )

    checklist_ok = coverage is None or coverage >= 70
    grounding_ok = bool(regulation_context_present) and checklist_ok and (
        citation_mean is None or citation_ok
    ) and trap_risk not in ("high", "critical")

    return {
        "score": score,
        "complete_sections": ok,
        "total": total,
        "incomplete": incomplete,
        # Grounding fields (P0 export soft-gate)
        "regulation_context_present": bool(regulation_context_present),
        "checklist_coverage": coverage,
        "checklist_ok": checklist_ok,
        "missing_sections": missing_sections[:12],
        "citation_mean": citation_mean,
        "citation_ok": citation_ok if citation_mean is not None else None,
        "trap_risk": trap_risk,
        "trap_count": trap_count,
        "grounding_ok": grounding_ok,
    }