File size: 23,404 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
"""
World-class regulation-grounded advisor (pure evaluation path).

Checks application content against a structured advisor brief without requiring
a live LLM — suitable for unit tests and as a hard gate before soft-pass export.
"""
from __future__ import annotations

import re
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Sequence


@dataclass
class AdvisorFinding:
    code: str
    severity: str  # critical | major | minor | info
    message: str
    category: str  # program_alignment | missing_required | quality | grounding
    section: str = ""
    blocking: bool = False

    def to_dict(self) -> Dict[str, Any]:
        return asdict(self)


@dataclass
class AdvisorReport:
    passed: bool
    score: int
    grounding_mode: str
    regulation_grounded_pass: bool
    findings: List[AdvisorFinding] = field(default_factory=list)
    blockers: List[str] = field(default_factory=list)
    covered_sections: List[str] = field(default_factory=list)
    missing_sections: List[str] = field(default_factory=list)
    missing_attachments_mentioned: List[str] = field(default_factory=list)
    attention_addressed: List[str] = field(default_factory=list)
    attention_open: List[str] = field(default_factory=list)
    brief_usable: bool = False
    summary: str = ""

    def to_dict(self) -> Dict[str, Any]:
        d = asdict(self)
        d["findings"] = [f if isinstance(f, dict) else f.to_dict() for f in self.findings]
        return d


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


# Generic tokens that must NOT count as rule hits (corporate fluff matches these)
_RULE_STOPWORDS = frozenset(
    {
        "wnioskodawca",
        "beneficjent",
        "projekt",
        "projekty",
        "musi",
        "muszą",
        "powinien",
        "powinna",
        "posiadać",
        "posiada",
        "status",
        "oraz",
        "przez",
        "który",
        "która",
        "które",
        "zgodnie",
        "zawierać",
        "zawiera",
        "następujące",
        "elementy",
        "wymagane",
        "wymagany",
        "opis",
        "treść",
        "sekcja",
        "program",
        "naboru",
        "regulamin",
        "sprawdź",
        "potwierdź",
        "udokumentuj",
        "przygotuj",
        "zweryfikuj",
        "uwzględnij",
        "punkt",
        "uwagi",
        "minimum",
        "wynosi",
        "kosztów",
        "koszty",
        "koszt",
        "zasada",
        "spełniać",
        "spełnia",
    }
)

# Domain signals that, if present in brief, must appear in the application
_CORE_SIGNAL_GROUPS: List[tuple[str, tuple[str, ...]]] = [
    ("mśp", ("mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior", "mikro firma")),
    ("dnsh", ("dnsh", "do no significant harm", "significant harm", "wpływ na środowisko", "wpływ środowisk")),
    ("wkład_własny", ("wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne")),
    ("de_minimis", ("de minimis", "pomoc publiczna", "pomocy publicznej")),
    ("trl", ("trl", "gotowości technologicz", "gotowosci technologicz")),
    ("niekwalifikowalne", ("niekwalifikow", "koszty niekwalifikowalne")),
]


def _blob_from_sections(sections: Optional[Dict[str, str]], document_text: str = "") -> str:
    parts: List[str] = []
    if document_text:
        parts.append(document_text)
    if sections:
        for title, body in sections.items():
            parts.append(f"## {title}\n{body or ''}")
    return "\n".join(parts)


def _section_present(required: str, sections: Dict[str, str], blob: str) -> bool:
    """True if required section has meaningful content under a matching title."""
    nr = _norm(required)
    if not nr:
        return True
    # Direct title match with substantial body only (no free-text weak match)
    for title, body in (sections or {}).items():
        nt = _norm(title)
        if nr in nt or nt in nr or difflib_ratio(nr, nt) >= 0.55:
            if body and len(body.strip()) >= 80 and "[UZUPEŁNIĆ" not in body:
                return True
    return False


def difflib_ratio(a: str, b: str) -> float:
    import difflib

    return difflib.SequenceMatcher(None, a, b).ratio()


def _distinctive_terms_from_rule(rule: str) -> List[str]:
    """
    Extract distinctive multi-token phrases / domain terms from a rule.
    Drops stopwords so 'Projekt musi…' alone never counts as alignment.
    """
    n = _norm(rule)
    terms: List[str] = []
    # Prefer known domain multi-word / acronyms first
    for _name, variants in _CORE_SIGNAL_GROUPS:
        for v in variants:
            if v in n:
                terms.append(v)
    # Multi-word chunks of 2–3 content words
    words = re.findall(r"[a-ząćęłńóśźż0-9%]{3,}", n)
    content = [w for w in words if w not in _RULE_STOPWORDS and len(w) >= 4]
    for i in range(len(content) - 1):
        bigram = f"{content[i]} {content[i + 1]}"
        if bigram not in terms:
            terms.append(bigram)
    # Long single tokens (≥7) that aren't stopwords — acronyms like mśp already handled
    for w in content:
        if len(w) >= 7 and w not in terms and w not in _RULE_STOPWORDS:
            terms.append(w)
    return terms[:12]


def rule_is_addressed(rule: str, blob: str) -> bool:
    """True only when distinctive signal(s) from the rule appear in application text."""
    b = _norm(blob)
    if not b or not rule:
        return False
    terms = _distinctive_terms_from_rule(rule)
    if not terms:
        # No distinctive content in rule → cannot claim hit from fluff
        return False
    # Need at least one multi-word term OR two distinct single-domain hits
    multi = [t for t in terms if " " in t or any(t in g[1] for g in _CORE_SIGNAL_GROUPS)]
    hits = [t for t in terms if t in b]
    if not hits:
        return False
    if any(t in b for t in multi):
        return True
    # Single long distinctive tokens: require ≥2 different hits for generic rules
    single_hits = [t for t in hits if " " not in t and len(t) >= 7]
    return len(set(single_hits)) >= 2 or (len(single_hits) >= 1 and any(t in b for t in multi))


def core_signals_required_by_brief(brief: Dict[str, Any]) -> List[str]:
    """Which core domain signals appear in brief (rules + attention + eligibility)."""
    blob = " ".join(
        [
            " ".join(str(x) for x in (brief.get("key_rules") or [])),
            " ".join(str(x) for x in (brief.get("attention_points") or [])),
            " ".join(str(x) for x in (brief.get("eligibility_signals") or [])),
            " ".join(str(x) for x in (brief.get("funding_limits") or [])),
        ]
    )
    n = _norm(blob)
    required: List[str] = []
    for name, variants in _CORE_SIGNAL_GROUPS:
        if any(v in n for v in variants):
            required.append(name)
    return required


def core_signal_present(name: str, blob: str) -> bool:
    b = _norm(blob)
    for gname, variants in _CORE_SIGNAL_GROUPS:
        if gname == name:
            return any(v in b for v in variants)
    return False


def _attention_is_critical(point: str) -> bool:
    p = _norm(point)
    critical_markers = (
        "dnsh",
        "środowisk",
        "srodowisk",
        "mśp",
        "msp",
        "wkład",
        "własn",
        "wlasn",
        "de minimis",
        "pomoc publiczn",
        "trl",
        "niekwalifikow",
    )
    return any(m in p for m in critical_markers)


def _attention_addressed(point: str, blob: str) -> bool:
    p = _norm(point)
    b = _norm(blob)
    keywords: List[str] = []
    if "dnsh" in p or "środowisk" in p or "srodowisk" in p:
        keywords = ["dnsh", "do no significant harm", "wpływ na środowisko", "wpływ środowisk", "środowisk", "srodowisk"]
    elif "mśp" in p or "msp" in p:
        keywords = ["mśp", "msp", "mikroprzedsiębior", "małe przedsiębior", "średnie przedsiębior"]
    elif "wkład" in p or "własn" in p or "wlasn" in p:
        keywords = ["wkład własny", "wklad wlasny", "finansowanie własne", "finansowanie wlasne"]
    elif "de minimis" in p or "pomoc publiczn" in p:
        keywords = ["de minimis", "pomoc publiczna", "pomocy publicznej"]
    elif "trl" in p:
        keywords = ["trl", "gotowości technologicz", "gotowosci technologicz"]
    elif "załącznik" in p or "zalacznik" in p:
        keywords = ["załącznik", "zalacznik", "oświadczenie", "oswiadczenie"]
    elif "niekwalifikow" in p:
        keywords = ["niekwalifikow", "koszty niekwalifikowalne"]
    else:
        # Require multi-token distinctive match, not lone stopwords
        keywords = _distinctive_terms_from_rule(point)[:4]
    if not keywords:
        return False
    return any(k in b for k in keywords)


def evaluate_application(
    *,
    document_text: str = "",
    sections: Optional[Dict[str, str]] = None,
    brief: Optional[Dict[str, Any]] = None,
    grounding_mode: str = "regulation",
    min_score: int = 70,
    min_section_chars: int = 80,
) -> AdvisorReport:
    """
    Evaluate application against regulation-derived brief.

    structure_only / blocked / blind modes never yield regulation_grounded_pass=True.
    """
    brief = brief if isinstance(brief, dict) else {}
    sections = sections if isinstance(sections, dict) else {}
    mode = (grounding_mode or "regulation").lower().strip()
    blob = _blob_from_sections(sections, document_text)
    findings: List[AdvisorFinding] = []
    score = 100

    # --- Grounding hard rules ---
    if mode in ("structure_only", "blocked", "blind"):
        findings.append(
            AdvisorFinding(
                code="GROUNDING_NOT_REGULATION",
                severity="critical",
                message=(
                    f"Tryb {mode}: ocena nie może zakończyć się regulation-grounded pass. "
                    "Brak ugruntowania w regulaminie naboru."
                ),
                category="grounding",
                blocking=True,
            )
        )
        score -= 40

    usable = bool(brief.get("usable")) if "usable" in brief else (
        bool(brief.get("key_rules") or brief.get("required_sections") or brief.get("required_attachments"))
    )
    if mode == "regulation" and not usable and not (
        brief.get("key_rules") or brief.get("required_sections")
    ):
        findings.append(
            AdvisorFinding(
                code="BRIEF_EMPTY",
                severity="critical",
                message="Brief doradcy pusty — brak reguł/sekcji z regulaminu. Nie można ugruntować oceny.",
                category="grounding",
                blocking=True,
            )
        )
        score -= 35

    # --- Instrument mismatch (Eurogranty vs SMART modules etc.) ---
    try:
        from core.projects.instrument_profile import (
            detect_instrument_mismatch,
            resolve_program_type,
        )

        prog_type = str(
            brief.get("program_type")
            or (brief.get("instrument_program_type") if isinstance(brief, dict) else "")
            or ""
        )
        # Allow caller to pass via document_text meta later; also scan section titles
        mismatch = detect_instrument_mismatch(
            program_type=prog_type or resolve_program_type(program_name=str(brief.get("name") or "")),
            document_text=blob,
            section_titles=list(sections.keys()),
        )
        if mismatch.get("mismatch"):
            for msg in mismatch.get("findings") or []:
                findings.append(
                    AdvisorFinding(
                        code="INSTRUMENT_MISMATCH",
                        severity="critical",
                        message=msg,
                        category="program_alignment",
                        blocking=bool(mismatch.get("blocking")),
                    )
                )
            score -= int(mismatch.get("score_penalty") or 0)
    except Exception:
        pass

    # --- Required sections (program alignment + missing elements) ---
    required_sections = list(brief.get("required_sections") or [])
    covered: List[str] = []
    missing: List[str] = []
    for req in required_sections:
        if _section_present(req, sections, blob):
            covered.append(req)
        else:
            missing.append(req)
            findings.append(
                AdvisorFinding(
                    code="MISSING_REQUIRED_SECTION",
                    severity="critical",
                    message=f"Brak wymaganej sekcji/treści: {req}",
                    category="missing_required",
                    section=req,
                    blocking=True,
                )
            )
            score -= 12

    # --- Key rules: distinctive multi-token / domain coverage (no fluff hits) ---
    rules = list(brief.get("key_rules") or [])
    rules_hit = 0
    rules_checked = rules[:12]
    for rule in rules_checked:
        if rule_is_addressed(rule, blob):
            rules_hit += 1
    if rules_checked and mode == "regulation":
        rule_ratio = rules_hit / max(len(rules_checked), 1)
        if rule_ratio < 0.5:
            findings.append(
                AdvisorFinding(
                    code="WEAK_RULE_ALIGNMENT",
                    severity="critical" if rule_ratio < 0.35 else "major",
                    message=(
                        f"Słabe dopasowanie do reguł regulaminu "
                        f"({rules_hit}/{len(rules_checked)} reguł z distinctive signals w treści)."
                    ),
                    category="program_alignment",
                    blocking=rule_ratio < 0.5,
                )
            )
            score -= 25 if rule_ratio < 0.35 else 12
        elif rule_ratio >= 0.7:
            score = min(100, score + 5)

    # --- Core domain signals required by brief must appear in application ---
    if mode == "regulation":
        for sig in core_signals_required_by_brief(brief):
            if not core_signal_present(sig, blob):
                findings.append(
                    AdvisorFinding(
                        code="MISSING_CORE_SIGNAL",
                        severity="critical",
                        message=f"Brak kluczowego sygnału regulaminu w treści wniosku: {sig}",
                        category="program_alignment",
                        blocking=True,
                    )
                )
                score -= 15

    # --- Attachments mentioned when brief requires them ---
    missing_att: List[str] = []
    for att in list(brief.get("required_attachments") or [])[:10]:
        na = _norm(att)[:40]
        if na and na[:12] not in _norm(blob):
            # Also check generic "załącznik" coverage
            if "załącznik" not in _norm(blob) and "zalacznik" not in _norm(blob):
                missing_att.append(att)
                findings.append(
                    AdvisorFinding(
                        code="ATTACHMENT_NOT_ADDRESSED",
                        severity="major",
                        message=f"Brak odniesienia do wymaganego załącznika: {att}",
                        category="missing_required",
                        blocking=False,
                    )
                )
                score -= 4

    # --- Attention points (critical ones block regulation pass) ---
    attention = list(brief.get("attention_points") or [])
    addressed: List[str] = []
    open_pts: List[str] = []
    for pt in attention:
        if _attention_addressed(pt, blob):
            addressed.append(pt)
        else:
            open_pts.append(pt)
            critical = _attention_is_critical(pt)
            findings.append(
                AdvisorFinding(
                    code="ATTENTION_OPEN",
                    severity="critical" if critical else "minor",
                    message=f"Punkt uwagi regulaminu niezaadresowany: {pt}",
                    category="quality",
                    blocking=critical,
                )
            )
            score -= 12 if critical else 3

    # --- Critical quality / readiness (empty/short sections) ---
    short_sections = 0
    for title, body in sections.items():
        body = body or ""
        if len(body.strip()) < min_section_chars or "[UZUPEŁNIĆ" in body:
            short_sections += 1
            findings.append(
                AdvisorFinding(
                    code="SECTION_TOO_THIN",
                    severity="major",
                    message=f"Sekcja zbyt krótka lub niekompletna: {title}",
                    category="quality",
                    section=title,
                    blocking=len(body.strip()) < 20,
                )
            )
            score -= 6
    if not sections and len(blob.strip()) < 120:
        findings.append(
            AdvisorFinding(
                code="DOCUMENT_EMPTY",
                severity="critical",
                message="Dokument wniosku pusty lub zbyt krótki.",
                category="quality",
                blocking=True,
            )
        )
        score -= 40

    score = max(0, min(100, score))
    blockers = [f.message for f in findings if f.blocking]
    critical = [f for f in findings if f.severity == "critical"]

    # Explicit: never soft-pass structure_only / blind / blocked as regulation-grounded
    if mode in ("structure_only", "blocked", "blind"):
        regulation_grounded_pass = False
    else:
        regulation_grounded_pass = (
            mode == "regulation"
            and usable
            and score >= min_score
            and not blockers
            and len(critical) == 0
        )

    if mode == "regulation":
        passed = regulation_grounded_pass
    elif mode == "structure_only":
        # Structural readiness only — never regulation_grounded_pass
        passed = (
            len(blob.strip()) >= 200
            and short_sections == 0
            and score >= max(40, min_score - 25)
            and not any(f.code == "DOCUMENT_EMPTY" for f in findings)
        )
    else:
        passed = False

    summary_bits = [
        f"score={score}",
        f"mode={mode}",
        f"missing_sections={len(missing)}",
        f"blockers={len(blockers)}",
        f"regulation_grounded_pass={regulation_grounded_pass}",
    ]
    return AdvisorReport(
        passed=passed,
        score=score,
        grounding_mode=mode,
        regulation_grounded_pass=regulation_grounded_pass,
        findings=findings,
        blockers=blockers,
        covered_sections=covered,
        missing_sections=missing,
        missing_attachments_mentioned=missing_att,
        attention_addressed=addressed,
        attention_open=open_pts,
        brief_usable=usable,
        summary="; ".join(summary_bits),
    )


def advisor_findings_to_rewrite_targets(
    report: AdvisorReport | Dict[str, Any],
    plan_titles: Sequence[str],
) -> Dict[str, List[str]]:
    """Map advisor findings onto quality_loop section targets."""
    from core.generation.quality_loop import section_title_match

    if isinstance(report, AdvisorReport):
        findings = report.findings
        missing = report.missing_sections
    else:
        findings = report.get("findings") or []
        missing = report.get("missing_sections") or []

    targets: Dict[str, List[str]] = {}
    for req in missing:
        matched = section_title_match(str(req), plan_titles)
        note = f"[world_class_advisor] Uzupełnij wymaganą treść regulaminu: {req}"
        if matched:
            targets.setdefault(matched, []).append(note)
        elif plan_titles:
            targets.setdefault(list(plan_titles)[0], []).append(note)

    for f in findings:
        if isinstance(f, AdvisorFinding):
            code, msg, section, sev = f.code, f.message, f.section, f.severity
        elif isinstance(f, dict):
            code = f.get("code") or "FINDING"
            msg = f.get("message") or ""
            section = f.get("section") or ""
            sev = f.get("severity") or "major"
        else:
            continue
        if code in ("GROUNDING_NOT_REGULATION", "BRIEF_EMPTY"):
            # global note on all titles (limited later by pick)
            for t in plan_titles:
                targets.setdefault(t, []).append(f"[world_class_advisor|{sev}] {msg}")
            break
        matched = section_title_match(section, plan_titles) if section else None
        line = f"[world_class_advisor|{sev}|{code}] {msg}"
        if matched:
            targets.setdefault(matched, []).append(line)
        elif plan_titles and sev == "critical":
            # Global alignment/core-signal issues: attach to budget/alignment-like titles if any,
            # never blindly rewrite the first healthy section (e.g. full Wstęp).
            if code in ("WEAK_RULE_ALIGNMENT", "MISSING_CORE_SIGNAL"):
                domain_keys = ("budżet", "budzet", "finans", "koszt", "opis", "innowacj", "dopasow")
                hit_any = False
                for t in plan_titles:
                    nt = _norm(t)
                    if any(k in nt for k in domain_keys):
                        targets.setdefault(t, []).append(line)
                        hit_any = True
                if not hit_any:
                    # last resort: last plan title (often budget/closing), not first intro
                    targets.setdefault(list(plan_titles)[-1], []).append(line)
            else:
                targets.setdefault(list(plan_titles)[0], []).append(line)
    return targets


def evaluate_from_generator_state(state: Dict[str, Any]) -> AdvisorReport:
    """Convenience: build brief + sections from generator/external_context state."""
    ext = state.get("external_context") if isinstance(state.get("external_context"), dict) else {}
    generated = state.get("generated_sections") if isinstance(state.get("generated_sections"), dict) else {}
    mode = str(ext.get("grounding_mode") or state.get("grounding_mode") or "regulation").lower()
    brief = dict(ext.get("advisor_brief") or {}) if isinstance(ext.get("advisor_brief"), dict) else {}
    if not brief:
        brief = {
            "key_rules": list(ext.get("regulation_key_rules") or ext.get("key_rules") or []),
            "required_sections": list(ext.get("required_sections") or []),
            "required_attachments": list(ext.get("required_attachments") or []),
            "attention_points": list(ext.get("attention_points") or []),
        }
        brief["usable"] = bool(
            brief["key_rules"] or brief["required_sections"] or brief["required_attachments"]
        )
    # Instrument family for mismatch detection
    try:
        from core.projects.instrument_profile import resolve_program_type

        brief["program_type"] = resolve_program_type(
            program_type=str(ext.get("instrument_program_type") or ext.get("program_type") or ""),
            program_name=str(ext.get("program_name") or ext.get("grant_name") or ""),
            grant_id=str(ext.get("grant_id") or ""),
        )
        brief["name"] = str(ext.get("program_name") or ext.get("grant_name") or "")
    except Exception:
        pass
    return evaluate_application(
        sections=generated,
        document_text=state.get("full_document") or "",
        brief=brief,
        grounding_mode=mode,
    )