diff --git a/.gitignore b/.gitignore index 2fb32df..bf02f28 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/modules/strategy-agent/Dockerfile b/modules/strategy-agent/Dockerfile new file mode 100644 index 0000000..ecd5304 --- /dev/null +++ b/modules/strategy-agent/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8010 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8010"] diff --git a/modules/strategy-agent/IA_Brief_adverse.pdf b/modules/strategy-agent/IA_Brief_adverse.pdf new file mode 100644 index 0000000..2fbb392 Binary files /dev/null and b/modules/strategy-agent/IA_Brief_adverse.pdf differ diff --git a/modules/strategy-agent/IA_Brief_baseline.pdf b/modules/strategy-agent/IA_Brief_baseline.pdf new file mode 100644 index 0000000..14664be Binary files /dev/null and b/modules/strategy-agent/IA_Brief_baseline.pdf differ diff --git a/modules/strategy-agent/IA_Brief_recovery.pdf b/modules/strategy-agent/IA_Brief_recovery.pdf new file mode 100644 index 0000000..28ba98c Binary files /dev/null and b/modules/strategy-agent/IA_Brief_recovery.pdf differ diff --git a/modules/strategy-agent/README.md b/modules/strategy-agent/README.md index 0a3671f..2e9fbb0 100644 --- a/modules/strategy-agent/README.md +++ b/modules/strategy-agent/README.md @@ -1,4 +1,253 @@ -# Strategy & Feedback Agent -Owner: Antonio Soto Grande -Goal: /recommend, /counterfactual, /brief + templates. +# Strategy & Feedback Agent — IA Edition +**Owner:** Antonio Soto Grande +**Version:** 0.3.0 +**Part of:** OpenPolicyStack +--- + +## Overview + +The Strategy & Feedback Agent (SFA) is an AI-based microservice that automates EU Impact Assessment (IA) artefacts aligned with the European Commission's Better Regulation framework. Given a structured IA blueprint and live open-data indicators, it scores policy options across six IA criteria, runs weight sensitivity analysis, generates evidence-linked stakeholder briefs, produces LLM-enhanced narratives, and exports professional PDF reports. + +The system is piloted on the **EU Quantum Act** Impact Assessment but is designed to be reusable across any EU technical legislation (Chips Act, Cloud infrastructure directive, etc.) by swapping the indicator catalogue. It is designed to be rerun each time new data is available, producing consistent, comparable, traceable outputs across time. + +--- + +## Architecture + +``` +AssessRequest + └── IABlueprint (legislation + options + objectives) + └── indicators[] ← Eurostat + CORDIS live fetch (or explicit values) + │ + ▼ + scoring.py MCDA weighted additive scoring (6 IA criteria) + explain.py Driver attribution (top-3 contributing criteria) + evidence.py Evidence payload + assumption collection + │ + ▼ + AssessResponse Ranked OptionScore[] + drivers + evidence + + │ + ▼ + sensitivity.py Weight variation across 4 stakeholder scenarios + │ + ▼ + SensitivityResponse Per-scenario rankings + stability flag + + │ + ├──▶ /brief Deterministic markdown brief (always works) + ├──▶ /brief/llm LLM-enhanced brief via Anthropic API + └──▶ /brief/pdf Professional 3-page PDF export +``` + +--- + +## API + +**Base URL:** `http://localhost:8010` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/health` | Liveness check | +| GET | `/indicators` | Fetch live Eurostat + CORDIS indicators | +| POST | `/assess` | Score policy options (MCDA) | +| POST | `/sensitivity` | Weight sensitivity analysis | +| POST | `/brief` | Deterministic evidence-linked markdown brief | +| POST | `/brief/llm` | LLM-enhanced brief (requires `ANTHROPIC_API_KEY`) | +| POST | `/brief/pdf` | Professional 3-page PDF export | + +--- + +## IA Criteria and Scoring Scale + +Each policy option is scored on six criteria derived from the EU Better Regulation framework: + +| Criterion | Weight (default) | +|-----------|-----------------| +| Economic impact | 0.25 | +| Competitiveness | 0.25 | +| Social impact | 0.15 | +| Feasibility | 0.15 | +| Environmental impact | 0.10 | +| Coherence | 0.10 | + +**Scale:** `-2` (strongly negative) to `+2` (strongly positive). +Every score is linked to an evidence item or carries an explicit assumption tag. + +--- + +## Data Sources + +| Indicator | Source | Dataset | +|-----------|--------|---------| +| EU GERD as % of GDP | Eurostat | `rd_e_gerdtot` | +| R&D personnel as % active pop | Eurostat | `rd_p_persocc` | +| High-tech employment % | Eurostat | `htec_emp_nat2` | +| Quantum project count | CORDIS | public search | +| Quantum funding (M EUR) | CORDIS | public search | + +All sources are free and require no authentication. +On fetch failure, the system falls back to proxy values with a `quality_flag: proxy` tag. + +--- + +## Run (Docker-first) + +```bash +# Build +docker build -t ops-strategy-agent modules/strategy-agent + +# Run (with LLM support) +docker run --rm -p 8010:8010 \ + -e ANTHROPIC_API_KEY="your-key-here" \ + ops-strategy-agent + +# Run (without LLM — /brief/llm falls back to deterministic) +docker run --rm -p 8010:8010 ops-strategy-agent + +# Health check +curl http://localhost:8010/health + +# Fetch live indicators +curl http://localhost:8010/indicators + +# Run baseline assessment +curl -X POST http://localhost:8010/assess \ + -H "Content-Type: application/json" \ + -d @modules/strategy-agent/examples/assess_request_baseline.json + +# Generate PDF brief +curl -X POST http://localhost:8010/brief/pdf \ + -H "Content-Type: application/json" \ + -d @modules/strategy-agent/examples/assess_response_baseline.json \ + --output modules/strategy-agent/IA_Brief_baseline.pdf +``` + +--- + +## Streamlit UI + +A Streamlit visualization app is included for interactive demonstration and exploration. + +**Requirements:** Python 3.10+ with streamlit and requests installed. + +```bash +pip install streamlit requests pandas +``` + +**Run (Docker service must be running first):** + +```bash +python -m streamlit run modules/strategy-agent/streamlit_app.py +``` + +The app opens automatically in your browser and provides: + +| Tab | Description | +|-----|-------------| +| 📊 MCDA Scoring | Full criteria scoring table, key drivers, per-option rationale | +| 🔄 Sensitivity Analysis | Weight scenario matrix showing ranking stability | +| 📝 Brief Comparison | Side-by-side deterministic vs LLM-enhanced brief | +| 📋 Evidence & Provenance | Full indicator table with quality flags and assumptions | + +The sidebar allows scenario selection (baseline / adverse / recovery) and PDF download. + +--- + +## PDF Brief + +The `/brief/pdf` endpoint generates a professional 3-page PDF: + +- **Page 1** — Executive summary (LLM narrative + option ranking table) +- **Page 2** — Full MCDA scoring table with rationales per option +- **Page 3** — Evidence payload, indicator notes, and explicit assumptions + +The PDF uses EU Commission colour styling and includes a full methodology note and traceability footer on every page. + +--- + +## LLM Layer + +The `/brief/llm` endpoint calls the Anthropic API (`claude-sonnet-4-20250514`) to generate a readable narrative brief constrained strictly to structured scoring outputs. The system prompt enforces: + +- No new facts beyond what is in the structured payload +- No changes to rankings, scores, or option names +- Explicit reflection of assumption and proxy flags +- Output under 400 words in plain accessible language + +If the API key is missing or the call fails, the endpoint falls back gracefully to the deterministic `/brief` output. The service never breaks the orchestrator. + +--- + +## Examples + +Golden request/response pairs are in `examples/` and serve as both documentation and integration tests: + +| File | Description | +|------|-------------| +| `assess_request_baseline.json` | Baseline scenario — live Eurostat/CORDIS fetch | +| `assess_response_baseline.json` | Expected response, opt-2 preferred (score: +1.425) | +| `assess_request_adverse.json` | Adverse shock — declining GERD + reduced funding | +| `assess_response_adverse.json` | Expected response, opt-3 preferred (score: +1.275) | +| `assess_request_recovery.json` | Recovery — rising GERD + oversubscribed quantum calls | +| `assess_response_recovery.json` | Expected response, opt-2 preferred (score: +1.625) | + +--- + +## Repository Structure + +``` +modules/strategy-agent/ +├── app/ +│ ├── main.py # FastAPI app — 6 endpoints +│ ├── schemas.py # Pydantic models (all I/O types) +│ ├── config.py # IA criteria weights + scoring anchors +│ ├── indicator_catalogue.py # Live Eurostat + CORDIS fetchers +│ ├── scoring.py # MCDA scoring engine +│ ├── sensitivity.py # Weight sensitivity analysis +│ ├── explain.py # Driver attribution +│ ├── evidence.py # Evidence payload builder +│ ├── llm_brief.py # Constrained LLM brief via Anthropic API +│ ├── pdf_brief.py # Professional PDF generator (reportlab) +│ └── utils.py # Shared helpers +├── examples/ +│ ├── assess_request_baseline.json +│ ├── assess_response_baseline.json +│ ├── assess_request_adverse.json +│ ├── assess_response_adverse.json +│ ├── assess_request_recovery.json +│ └── assess_response_baseline.json +├── streamlit_app.py # Interactive Streamlit UI +├── Dockerfile +├── requirements.txt +└── README.md +``` + +--- + +## Evidence and Provenance + +Every output includes a structured evidence payload. Each item records: +- `indicator_id` — unique indicator reference +- `source_type` — `eurostat` | `cordis` | `derived` | `manual` +- `source_ref` — dataset code or endpoint (e.g. `eurostat:rd_e_gerdtot`) +- `field_path` — specific field used (e.g. `gerd_pct_gdp.latest`) +- `quality_flag` — `verified` | `proxy` | `assumption` | `stale` + +The `no evidence → no claim` rule is enforced: any criteria score without a referenced indicator must carry an explicit `assumption` string. + +--- + +## Sensitivity Analysis + +Four weight scenarios test ranking robustness: + +| Scenario | Focus | +|----------|-------| +| Economic priority | Elevated weights on economic + competitiveness criteria | +| Social/environmental priority | Elevated weights on social + environmental criteria | +| Feasibility priority | Elevated weight on feasibility (risk-averse perspective) | +| Equal weights | All criteria weighted equally (baseline sensitivity check) | + +Results show whether the preferred option is stable across stakeholder value assumptions. \ No newline at end of file diff --git a/modules/strategy-agent/app/config.py b/modules/strategy-agent/app/config.py new file mode 100644 index 0000000..c17887c --- /dev/null +++ b/modules/strategy-agent/app/config.py @@ -0,0 +1,85 @@ +""" +config.py — IA criteria weights and scoring configuration. +Uses plain string keys throughout to avoid enum import issues. +""" + +DEFAULT_WEIGHTS: dict = { + "economic": 0.25, + "social": 0.15, + "environmental": 0.10, + "competitiveness": 0.25, + "feasibility": 0.15, + "coherence": 0.10, +} + +SCORING_ANCHORS: dict = { + "economic": { + "+2": "Strong positive GDP/investment impact, well-evidenced", + "+1": "Moderate positive economic effect", + "0": "Neutral or negligible economic impact", + "-1": "Moderate negative effect on costs or market", + "-2": "Strong negative economic impact or significant compliance cost", + }, + "social": { + "+2": "Major improvement in jobs, skills, or inclusion", + "+1": "Moderate positive social effect", + "0": "No significant social impact", + "-1": "Some negative social effect", + "-2": "Significant negative social consequences", + }, + "environmental": { + "+2": "Strong reduction in environmental footprint", + "+1": "Moderate environmental benefit", + "0": "Neutral environmental impact", + "-1": "Minor negative environmental effect", + "-2": "Significant environmental harm", + }, + "competitiveness": { + "+2": "Major boost to EU strategic autonomy and global tech leadership", + "+1": "Moderate improvement in competitiveness", + "0": "No significant effect on competitiveness", + "-1": "Slight competitiveness disadvantage", + "-2": "Significant competitive harm vs non-EU players", + }, + "feasibility": { + "+2": "Highly feasible, low implementation risk, clear governance", + "+1": "Feasible with manageable challenges", + "0": "Uncertain feasibility", + "-1": "Difficult to implement, high risk", + "-2": "Not feasible in current context", + }, + "coherence": { + "+2": "Fully coherent with EU objectives and existing regulation", + "+1": "Largely coherent with minor tensions", + "0": "Neutral coherence", + "-1": "Some tension with existing policy frameworks", + "-2": "Significant incoherence or contradiction with EU law", + }, +} + +SENSITIVITY_WEIGHT_GRID: list = [ + { + "label": "economic_priority", + "economic": 0.40, "social": 0.10, "environmental": 0.05, + "competitiveness": 0.30, "feasibility": 0.10, "coherence": 0.05, + }, + { + "label": "social_env_priority", + "economic": 0.15, "social": 0.25, "environmental": 0.25, + "competitiveness": 0.15, "feasibility": 0.10, "coherence": 0.10, + }, + { + "label": "feasibility_priority", + "economic": 0.20, "social": 0.10, "environmental": 0.05, + "competitiveness": 0.20, "feasibility": 0.35, "coherence": 0.10, + }, + { + "label": "equal_weights", + "economic": 0.167, "social": 0.167, "environmental": 0.167, + "competitiveness": 0.167, "feasibility": 0.167, "coherence": 0.165, + }, +] + +TOP_K_DRIVERS = 3 +SCORE_MIN = -2.0 +SCORE_MAX = 2.0 diff --git a/modules/strategy-agent/app/evidence.py b/modules/strategy-agent/app/evidence.py new file mode 100644 index 0000000..2800138 --- /dev/null +++ b/modules/strategy-agent/app/evidence.py @@ -0,0 +1,70 @@ +""" +evidence.py — Build the evidence payload for an AssessResponse. + +Collects all indicator_ids referenced in criteria scores and maps +them back to full EvidenceItem objects from the indicator list. +""" + +from typing import List +from app.schemas import OptionScore, IndicatorValue, EvidenceItem, SourceType, QualityFlag + + +def build_evidence( + option_scores: List[OptionScore], + indicators: List[IndicatorValue], +) -> List[EvidenceItem]: + """ + Return deduplicated EvidenceItems for all indicators referenced + across any criteria score in any option. + """ + ind_map = {i.indicator_id: i for i in indicators} + + referenced_ids: set[str] = set() + for os in option_scores: + for cs in os.criteria_scores: + for eid in cs.evidence_ids: + referenced_ids.add(eid) + + evidence: List[EvidenceItem] = [] + for ind_id in sorted(referenced_ids): + ind = ind_map.get(ind_id) + if ind: + evidence.append(EvidenceItem( + indicator_id=ind.indicator_id, + source_type=ind.source_type, + source_ref=ind.source_ref, + field_path=f"{ind.indicator_id}.{ind.year or 'latest'}", + value=ind.value, + unit=ind.unit, + quality_flag=ind.quality_flag, + note=ind.note, + )) + else: + # Referenced but not in catalogue — flag as assumption + evidence.append(EvidenceItem( + indicator_id=ind_id, + source_type=SourceType.MANUAL, + source_ref="unknown", + field_path=ind_id, + quality_flag=QualityFlag.ASSUMPTION, + note=f"Indicator '{ind_id}' referenced in scoring but not found in catalogue.", + )) + + return evidence + + +def collect_assumptions(option_scores: List[OptionScore]) -> List[str]: + """ + Collect all explicit assumption strings from criteria scores + where no evidence was available. + """ + assumptions = [] + seen = set() + for os in option_scores: + for cs in os.criteria_scores: + if cs.assumption and cs.assumption not in seen: + assumptions.append( + f"[{os.option_name} / {cs.criterion}] {cs.assumption}" + ) + seen.add(cs.assumption) + return assumptions \ No newline at end of file diff --git a/modules/strategy-agent/app/explain.py b/modules/strategy-agent/app/explain.py new file mode 100644 index 0000000..7e417a1 --- /dev/null +++ b/modules/strategy-agent/app/explain.py @@ -0,0 +1,29 @@ +""" +explain.py — Driver attribution from criteria scores. +Uses plain string criterion keys. +""" + +from typing import List +from app.schemas import OptionScore, Driver +from app.config import DEFAULT_WEIGHTS, TOP_K_DRIVERS + + +def generate_drivers(preferred_option: OptionScore, weights: dict = None) -> List[Driver]: + if weights is None: + weights = DEFAULT_WEIGHTS + + contributions = [] + for cs in preferred_option.criteria_scores: + w = weights.get(cs.criterion, 1 / len(DEFAULT_WEIGHTS)) + contribution = w * cs.score + direction = "positive" if contribution > 0 else "negative" if contribution < 0 else "neutral" + source_ref = ", ".join(cs.evidence_ids) if cs.evidence_ids else None + contributions.append(Driver( + criterion=cs.criterion, + direction=direction, + contribution=round(abs(contribution), 4), + source_ref=source_ref, + )) + + contributions.sort(key=lambda d: d.contribution, reverse=True) + return contributions[:TOP_K_DRIVERS] \ No newline at end of file diff --git a/modules/strategy-agent/app/indicator_catalogue.py b/modules/strategy-agent/app/indicator_catalogue.py new file mode 100644 index 0000000..96c597a --- /dev/null +++ b/modules/strategy-agent/app/indicator_catalogue.py @@ -0,0 +1,288 @@ +""" +indicator_catalogue.py — Live data fetcher for the Quantum Act IA pilot. + +Sources: + - Eurostat REST API (no auth required) + Base: https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/ + - CORDIS public search (no auth required) + Base: https://cordis.europa.eu/search/results_en + +All fetched values are returned as IndicatorValue objects with full provenance. +On fetch failure, falls back to a PROXY or ASSUMPTION value with a quality flag. +""" + +import httpx +import logging +from typing import Optional + +from app.schemas import IndicatorValue, SourceType, QualityFlag + +logger = logging.getLogger(__name__) + +EUROSTAT_BASE = ( + "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data" +) +CORDIS_SEARCH_BASE = "https://cordis.europa.eu/search/results_en" + +# ─── Eurostat helpers ───────────────────────────────────────────────────────── + +def _eurostat_url(dataset_code: str, params: dict) -> str: + query = "&".join(f"{k}={v}" for k, v in params.items()) + return f"{EUROSTAT_BASE}/{dataset_code}?format=JSON&lang=EN&{query}" + + +def _fetch_eurostat( + dataset_code: str, + params: dict, + value_key: str, +) -> Optional[float]: + """ + Fetch a single scalar value from a Eurostat dataset. + Returns None on any error so callers can fall back gracefully. + """ + url = _eurostat_url(dataset_code, params) + try: + resp = httpx.get(url, timeout=10.0) + resp.raise_for_status() + data = resp.json() + values = list(data.get("value", {}).values()) + if values: + return float(values[-1]) # most recent non-null value + return None + except Exception as exc: + logger.warning("Eurostat fetch failed for %s: %s", dataset_code, exc) + return None + + +# ─── CORDIS helper ──────────────────────────────────────────────────────────── + +def _fetch_cordis_project_count(keyword: str) -> Optional[int]: + """ + Query the CORDIS public search for projects matching a keyword. + Returns total hit count, or None on failure. + """ + params = { + "q": keyword, + "p": "1", + "num": "1", + "srt": "/project/contentUpdateDate:decreasing", + "format": "json", + } + try: + resp = httpx.get(CORDIS_SEARCH_BASE, params=params, timeout=10.0) + resp.raise_for_status() + data = resp.json() + # CORDIS returns {"totalHits": N, "results": [...]} + return int(data.get("totalHits", 0)) + except Exception as exc: + logger.warning("CORDIS fetch failed for keyword '%s': %s", keyword, exc) + return None + + +def _fetch_cordis_total_funding(keyword: str) -> Optional[float]: + """ + Approximate total EC contribution for projects matching a keyword. + Sums ecMaxContribution across the first page of results (100 items). + Returns value in millions EUR, or None on failure. + """ + params = { + "q": keyword, + "p": "1", + "num": "100", + "srt": "/project/contentUpdateDate:decreasing", + "format": "json", + } + try: + resp = httpx.get(CORDIS_SEARCH_BASE, params=params, timeout=15.0) + resp.raise_for_status() + data = resp.json() + results = data.get("results", []) + total = sum( + float(r.get("ecMaxContribution", 0) or 0) + for r in results + ) + return round(total / 1_000_000, 2) # convert to M EUR + except Exception as exc: + logger.warning("CORDIS funding fetch failed: %s", exc) + return None + + +# ─── Public fetch functions ─────────────────────────────────────────────────── + +def fetch_gerd_total(geo: str = "EU27_2020") -> IndicatorValue: + """ + Total intramural R&D expenditure (GERD) as % of GDP. + Eurostat dataset: rd_e_gerdtot + """ + value = _fetch_eurostat( + "rd_e_gerdtot", + {"geo": geo, "unit": "PC_GDP", "lastTimePeriod": "1"}, + value_key="value", + ) + if value is not None: + return IndicatorValue( + indicator_id="gerd_pct_gdp", + name="EU GERD as % of GDP", + value=value, + unit="% of GDP", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:rd_e_gerdtot", + quality_flag=QualityFlag.VERIFIED, + note=f"Total R&D expenditure, {geo}, latest available year", + ) + # fallback + return IndicatorValue( + indicator_id="gerd_pct_gdp", + name="EU GERD as % of GDP", + value=2.22, + unit="% of GDP", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:rd_e_gerdtot", + quality_flag=QualityFlag.PROXY, + note="Eurostat fetch failed; using 2022 published value as proxy", + ) + + +def fetch_rd_personnel(geo: str = "EU27_2020") -> IndicatorValue: + """ + Total R&D personnel (researchers) as % of active population. + Eurostat dataset: rd_p_persocc + """ + value = _fetch_eurostat( + "rd_p_persocc", + {"geo": geo, "unit": "PC_ACT_POP", "sex": "T", + "prof_pos": "TOTAL", "lastTimePeriod": "1"}, + value_key="value", + ) + if value is not None: + return IndicatorValue( + indicator_id="rd_personnel_pct", + name="EU R&D personnel as % of active population", + value=value, + unit="% active population", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:rd_p_persocc", + quality_flag=QualityFlag.VERIFIED, + note=f"All sectors, both sexes, {geo}, latest available", + ) + return IndicatorValue( + indicator_id="rd_personnel_pct", + name="EU R&D personnel as % of active population", + value=1.43, + unit="% active population", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:rd_p_persocc", + quality_flag=QualityFlag.PROXY, + note="Eurostat fetch failed; using 2022 published value as proxy", + ) + + +def fetch_hightech_employment(geo: str = "EU27_2020") -> IndicatorValue: + """ + Employment in high-technology sectors (% of total employment). + Eurostat dataset: htec_emp_nat + """ + value = _fetch_eurostat( + "htec_emp_nat", + {"geo": geo, "unit": "PC_EMP", "lastTimePeriod": "1"}, + value_key="value", + ) + if value is not None: + return IndicatorValue( + indicator_id="hightech_employment_pct", + name="EU high-tech employment as % of total employment", + value=value, + unit="% of total employment", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:htec_emp_nat", + quality_flag=QualityFlag.VERIFIED, + note=f"High-tech sectors, {geo}, latest available", + ) + return IndicatorValue( + indicator_id="hightech_employment_pct", + name="EU high-tech employment as % of total employment", + value=4.8, + unit="% of total employment", + source_type=SourceType.EUROSTAT, + source_ref="eurostat:htec_emp_nat", + quality_flag=QualityFlag.PROXY, + note="Eurostat fetch failed; using 2022 published value as proxy", + ) + + +def fetch_quantum_project_count() -> IndicatorValue: + """ + Number of EU-funded projects with 'quantum' in title/description. + Source: CORDIS public search. + """ + count = _fetch_cordis_project_count("quantum") + if count is not None: + return IndicatorValue( + indicator_id="cordis_quantum_projects", + name="EU-funded quantum research projects (CORDIS)", + value=float(count), + unit="projects", + source_type=SourceType.CORDIS, + source_ref="cordis:search?q=quantum", + quality_flag=QualityFlag.VERIFIED, + note="Total CORDIS project hits for keyword 'quantum'", + ) + return IndicatorValue( + indicator_id="cordis_quantum_projects", + name="EU-funded quantum research projects (CORDIS)", + value=320.0, + unit="projects", + source_type=SourceType.CORDIS, + source_ref="cordis:search?q=quantum", + quality_flag=QualityFlag.PROXY, + note="CORDIS fetch failed; using 2024 approximate count as proxy", + ) + + +def fetch_quantum_funding() -> IndicatorValue: + """ + Approximate total EC funding for quantum projects (M EUR). + Source: CORDIS public search, first 100 results. + """ + funding = _fetch_cordis_total_funding("quantum") + if funding is not None: + return IndicatorValue( + indicator_id="cordis_quantum_funding_meur", + name="EU quantum project funding — sample total (M EUR)", + value=funding, + unit="M EUR", + source_type=SourceType.CORDIS, + source_ref="cordis:search?q=quantum", + quality_flag=QualityFlag.PROXY, + note=( + "Sum of ecMaxContribution for top-100 CORDIS results. " + "Proxy for total EU quantum R&D investment signal." + ), + ) + return IndicatorValue( + indicator_id="cordis_quantum_funding_meur", + name="EU quantum project funding — sample total (M EUR)", + value=1200.0, + unit="M EUR", + source_type=SourceType.CORDIS, + source_ref="cordis:search?q=quantum", + quality_flag=QualityFlag.ASSUMPTION, + note="CORDIS fetch failed; assumption based on Quantum Flagship budget", + ) + + +# ─── Master fetch ───────────────────────────────────────────────────────────── + +def fetch_all_indicators(geo: str = "EU27_2020") -> list[IndicatorValue]: + """ + Fetch all five Quantum Act pilot indicators. + Returns a list ready to pass directly into AssessRequest.indicators. + Failures fall back gracefully — never raises. + """ + return [ + fetch_gerd_total(geo), + fetch_rd_personnel(geo), + fetch_hightech_employment(geo), + fetch_quantum_project_count(), + fetch_quantum_funding(), + ] \ No newline at end of file diff --git a/modules/strategy-agent/app/llm_brief.py b/modules/strategy-agent/app/llm_brief.py new file mode 100644 index 0000000..72e2c8d --- /dev/null +++ b/modules/strategy-agent/app/llm_brief.py @@ -0,0 +1,116 @@ +""" +llm_brief.py — LLM-enhanced brief generator using the Anthropic API. + +DESIGN CONSTRAINTS (enforced via system prompt): + - The LLM receives ONLY structured scoring outputs — no external knowledge + - It is explicitly instructed not to introduce new facts or claims + - Every claim must be traceable to a field in the structured payload + - If the API call fails, main.py falls back to the deterministic /brief output + +The LLM's role is purely presentational: it rewrites the structured +brief into clearer, more readable prose for non-technical stakeholders. +It does not score, rank, or recommend — that is done by scoring.py. +""" + +import os +import anthropic +from app.schemas import BriefRequest + +ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") +MODEL = "claude-sonnet-4-20250514" + +SYSTEM_PROMPT = """You are a policy analyst assistant helping to write Impact Assessment briefs for the European Commission. + +Your role is strictly presentational: you receive structured scoring data and rewrite it into clear, concise prose suitable for non-technical stakeholders such as senior policy officials and ministers. + +ABSOLUTE RULES you must never break: +1. You may ONLY use information explicitly present in the structured data provided. Do not introduce any external facts, statistics, or claims. +2. Do not add any numbers, percentages, or figures that are not in the structured data. +3. Do not make recommendations beyond what the scoring data already states. +4. Do not change any rankings, scores, or option names. +5. If a field is marked as an assumption or proxy, reflect that uncertainty in your language. +6. Keep the brief under 400 words. +7. Use plain, accessible language — avoid jargon. +8. Always end with the evidence quality note from the structured data. + +You are a presentation layer only. The analysis has already been done. Your job is clarity, not intelligence.""" + + +def _build_user_prompt(req: BriefRequest) -> str: + preferred = next( + (os for os in req.option_scores if os.rank == 1), + req.option_scores[0] if req.option_scores else None + ) + + preferred_name = preferred.option_name if preferred else "N/A" + preferred_score = f"{preferred.weighted_total:+.3f}" if preferred else "N/A" + + ranking = "\n".join( + f" Rank {os.rank}: {os.option_name} (weighted score: {os.weighted_total:+.3f}, status: {os.status})" + for os in sorted(req.option_scores, key=lambda x: x.rank) + ) + + drivers = "\n".join( + f" - {d.criterion} ({d.direction}, contribution: {d.contribution:.3f})" + + (f" — evidence: {d.source_ref}" if d.source_ref else "") + for d in req.drivers + ) + + verified = sum(1 for e in req.evidence if e.quality_flag == "verified") + total = len(req.evidence) + quality = f"{verified}/{total} indicators verified from official sources." + + assumptions = "\n".join(f" - {a}" for a in req.assumptions) \ + if req.assumptions else " None recorded." + + return f"""Please write a clear, concise Impact Assessment brief using ONLY the structured data below. +Do not add any information not present here. + +SCENARIO: {req.scenario_id} +RUN ID: {req.run_id} +OBJECTIVE: {req.objective or "EU Quantum Act option comparison"} + +PREFERRED OPTION: + {preferred_name} (score: {preferred_score}) + +FULL OPTION RANKING: +{ranking} + +KEY DRIVERS OF PREFERRED OPTION: +{drivers} + +EVIDENCE QUALITY: + {quality} + +EXPLICIT ASSUMPTIONS (where no indicator data was available): +{assumptions} + +Write the brief now. Structure it with these sections: +1. Context and objective (1-2 sentences) +2. Preferred option and rationale (2-3 sentences, grounded in the drivers above) +3. Option comparison summary (2-3 sentences) +4. Evidence quality and assumptions (1-2 sentences) + +End with: "This brief was generated from structured scoring outputs only. All claims are traceable to the evidence payload." +""" + + +def generate_llm_brief(req: BriefRequest) -> str: + if not ANTHROPIC_API_KEY: + raise ValueError( + "ANTHROPIC_API_KEY environment variable is not set. " + "Cannot call LLM brief endpoint without an API key." + ) + + client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) + + message = client.messages.create( + model=MODEL, + max_tokens=1000, + system=SYSTEM_PROMPT, + messages=[ + {"role": "user", "content": _build_user_prompt(req)} + ] + ) + + return message.content[0].text \ No newline at end of file diff --git a/modules/strategy-agent/app/main.py b/modules/strategy-agent/app/main.py new file mode 100644 index 0000000..3871d7d --- /dev/null +++ b/modules/strategy-agent/app/main.py @@ -0,0 +1,279 @@ +""" +main.py — Strategy & Feedback Agent (SFA) — IA Edition v0.3.0 + +Endpoints: + GET /health liveness check + GET /indicators fetch live Eurostat + CORDIS indicators + POST /assess score policy options (MCDA) + POST /sensitivity weight sensitivity analysis + POST /brief deterministic evidence-linked brief (always works) + POST /brief/llm LLM-enhanced brief (requires ANTHROPIC_API_KEY) +""" + +from fastapi import FastAPI, HTTPException +from app.schemas import ( + AssessRequest, AssessResponse, + SensitivityRequest, SensitivityResponse, + BriefRequest, BriefResponse, + IndicatorValue, +) +from app.scoring import score_options +from app.sensitivity import run_sensitivity +from app.explain import generate_drivers +from app.evidence import build_evidence, collect_assumptions +from app.indicator_catalogue import fetch_all_indicators +from app.llm_brief import generate_llm_brief + +app = FastAPI( + title="Strategy & Feedback Agent — IA Edition", + version="0.3.0", + description=( + "AI microservice for EU Impact Assessment option comparison. " + "Implements MCDA scoring, sensitivity analysis, deterministic " + "evidence-linked brief generation, and an LLM-enhanced brief " + "endpoint constrained strictly to structured scoring outputs." + ), +) + + +# ─── Health ─────────────────────────────────────────────────────────────────── + +@app.get("/health") +def health(): + return {"status": "ok", "version": "0.3.0"} + + +# ─── Indicators ─────────────────────────────────────────────────────────────── + +@app.get("/indicators", response_model=list[IndicatorValue]) +def get_indicators(geo: str = "EU27_2020"): + """Fetch all live Quantum Act pilot indicators from Eurostat and CORDIS.""" + return fetch_all_indicators(geo=geo) + + +# ─── Assess ─────────────────────────────────────────────────────────────────── + +@app.post("/assess", response_model=AssessResponse) +def assess(req: AssessRequest): + """ + Score all policy options in the IA blueprint against IA criteria. + If req.indicators is empty, live indicators are fetched automatically. + """ + indicators = req.indicators + if not indicators: + indicators = fetch_all_indicators() + + if not req.blueprint.policy_options: + raise HTTPException( + status_code=422, + detail="blueprint.policy_options must contain at least one option.", + ) + + option_scores = score_options(blueprint=req.blueprint, indicators=indicators) + preferred = next((os for os in option_scores if os.rank == 1), option_scores[0]) + drivers = generate_drivers(preferred) + evidence = build_evidence(option_scores, indicators) + assumptions = collect_assumptions(option_scores) + + return AssessResponse( + run_id=req.run_id, + scenario_id=req.scenario_id, + option_scores=option_scores, + drivers=drivers, + assumptions=assumptions, + evidence=evidence, + ) + + +# ─── Sensitivity ────────────────────────────────────────────────────────────── + +@app.post("/sensitivity", response_model=SensitivityResponse) +def sensitivity(req: SensitivityRequest): + """Run weight sensitivity analysis on already-scored options.""" + if not req.option_scores: + raise HTTPException(status_code=422, detail="option_scores must not be empty.") + + response = run_sensitivity(req.option_scores, req.weight_grid) + response.run_id = req.run_id + response.scenario_id = req.scenario_id + return response + + +# ─── Brief (deterministic) ──────────────────────────────────────────────────── + +@app.post("/brief", response_model=BriefResponse) +def brief(req: BriefRequest): + """ + Generate a deterministic evidence-linked markdown brief from structured + scoring outputs. No LLM involved — always works, fully traceable. + """ + if not req.option_scores: + raise HTTPException(status_code=422, detail="option_scores must not be empty.") + + preferred = next( + (os for os in req.option_scores if os.rank == 1), req.option_scores[0] + ) + + proxy_count = sum(1 for e in req.evidence if e.quality_flag in ("proxy", "assumption")) + total_evidence = len(req.evidence) + quality_note = ( + f"{total_evidence - proxy_count}/{total_evidence} indicators verified from official sources." + if total_evidence > 0 else "No indicators attached." + ) + + driver_lines = "\n".join( + f"- **{d.criterion}** ({d.direction}, contribution: {d.contribution:.3f})" + + (f" — {d.source_ref}" if d.source_ref else "") + for d in req.drivers + ) or "_No drivers computed._" + + ranking_lines = "\n".join( + f"| {os.rank} | {os.option_name} | {os.weighted_total:+.3f} | {os.status} |" + for os in sorted(req.option_scores, key=lambda x: x.rank) + ) + + assumption_lines = "\n".join( + f"- {a}" for a in req.assumptions + ) or "_No explicit assumptions recorded._" + + brief_md = f"""## Impact Assessment Brief — {req.scenario_id} + +**Objective:** {req.objective or "EU Quantum Act option comparison"} +**Run ID:** {req.run_id} + +--- + +### Preferred Option +**{preferred.option_name}** (weighted score: {preferred.weighted_total:+.3f}) + +### Option Ranking + +| Rank | Option | Score | Status | +|------|--------|-------|--------| +{ranking_lines} + +### Key Drivers (preferred option) + +{driver_lines} + +### Evidence Quality +{quality_note} + +### Assumptions +{assumption_lines} + +--- +_Brief generated from structured scoring outputs only. All claims are traceable to the evidence payload._ +""" + + return BriefResponse( + run_id=req.run_id, + scenario_id=req.scenario_id, + brief_markdown=brief_md, + option_scores=req.option_scores, + drivers=req.drivers, + assumptions=req.assumptions, + evidence=req.evidence, + ) + + +# ─── Brief (LLM-enhanced) ───────────────────────────────────────────────────── + +@app.post("/brief/llm", response_model=BriefResponse) +def brief_llm(req: BriefRequest): + """ + Generate an LLM-enhanced brief using the Anthropic API. + + The LLM receives ONLY structured scoring outputs and is constrained + by a strict system prompt to introduce no new facts. Falls back to + the deterministic /brief output if the API key is missing or the + API call fails, ensuring the endpoint never breaks the orchestrator. + + Requires ANTHROPIC_API_KEY environment variable to be set. + """ + if not req.option_scores: + raise HTTPException(status_code=422, detail="option_scores must not be empty.") + + try: + llm_markdown = generate_llm_brief(req) + brief_md = llm_markdown + "\n\n---\n_Brief generated by LLM from structured scoring outputs only. All claims are traceable to the evidence payload._" + + except Exception as exc: + # Graceful fallback — return deterministic brief with warning + preferred = next( + (os for os in req.option_scores if os.rank == 1), req.option_scores[0] + ) + ranking_lines = "\n".join( + f"| {os.rank} | {os.option_name} | {os.weighted_total:+.3f} | {os.status} |" + for os in sorted(req.option_scores, key=lambda x: x.rank) + ) + brief_md = f"""## Impact Assessment Brief — {req.scenario_id} (fallback) + +> **Note:** LLM generation unavailable ({str(exc)}). Returning deterministic brief. + +**Preferred Option:** {preferred.option_name} (score: {preferred.weighted_total:+.3f}) + +### Option Ranking + +| Rank | Option | Score | Status | +|------|--------|-------|--------| +{ranking_lines} + +--- +_Fallback brief generated from structured scoring outputs only._ +""" + + return BriefResponse( + run_id=req.run_id, + scenario_id=req.scenario_id, + brief_markdown=brief_md, + option_scores=req.option_scores, + drivers=req.drivers, + assumptions=req.assumptions, + evidence=req.evidence, + ) + + +# ─── Brief (PDF export) ─────────────────────────────────────────────────────── + +from fastapi.responses import StreamingResponse +import io + +@app.post("/brief/pdf") +def brief_pdf(req: BriefRequest): + """ + Generate a professional 3-page PDF brief: + Page 1 — Executive summary (LLM-generated narrative) + Page 2 — Full MCDA scoring table with rationales + Page 3 — Evidence payload and assumptions + + Falls back to deterministic brief text if LLM is unavailable. + Returns a downloadable PDF file. + """ + from app.pdf_brief import generate_pdf_brief + + if not req.option_scores: + raise HTTPException(status_code=422, detail="option_scores must not be empty.") + + # Get LLM brief or fall back to deterministic + try: + llm_text = generate_llm_brief(req) + except Exception: + preferred = next( + (os for os in req.option_scores if os.rank == 1), req.option_scores[0] + ) + llm_text = ( + f"Preferred Option: {preferred.option_name} " + f"(score: {preferred.weighted_total:+.3f})\n\n" + "This brief was generated from structured scoring outputs only. " + "All claims are traceable to the evidence payload." + ) + + pdf_bytes = generate_pdf_brief(req, llm_text) + + filename = f"IA_Brief_{req.scenario_id}_{req.run_id}.pdf" + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) \ No newline at end of file diff --git a/modules/strategy-agent/app/pdf_brief.py b/modules/strategy-agent/app/pdf_brief.py new file mode 100644 index 0000000..89e6be3 --- /dev/null +++ b/modules/strategy-agent/app/pdf_brief.py @@ -0,0 +1,434 @@ +""" +pdf_brief.py — Professional PDF brief generator for IA outputs. + +Produces a structured 3-section PDF: + Page 1: Executive Summary (LLM-generated narrative) + Page 2: Full MCDA Scoring Table (all options x all criteria) + Page 3: Evidence & Assumptions (provenance payload) + +Uses reportlab — pure Python, no system dependencies. +""" + +import io +import re +from datetime import datetime +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + HRFlowable, PageBreak +) + +from app.schemas import BriefRequest + +# ─── Colour palette ─────────────────────────────────────────────────────────── +EU_BLUE = colors.HexColor("#003399") +EU_BLUE_LIGHT = colors.HexColor("#E8EEF9") +EU_GOLD = colors.HexColor("#FFCC00") +DARK_GREY = colors.HexColor("#333333") +MID_GREY = colors.HexColor("#666666") +LIGHT_GREY = colors.HexColor("#F5F5F5") +WHITE = colors.white +GREEN = colors.HexColor("#1a7a1a") +RED = colors.HexColor("#cc0000") +AMBER = colors.HexColor("#cc6600") + + +def _clean_markdown(text: str) -> str: + """Remove markdown formatting so it renders as clean text in PDF.""" + # Remove bold/italic markers + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) + # Remove markdown headers + text = re.sub(r'^#{1,3}\s+', '', text, flags=re.MULTILINE) + return text.strip() + + +def _styles(): + return { + "title": ParagraphStyle("title", fontSize=20, textColor=EU_BLUE, + fontName="Helvetica-Bold", spaceBefore=0, + spaceAfter=8, alignment=TA_LEFT), + "subtitle": ParagraphStyle("subtitle", fontSize=11, textColor=MID_GREY, + fontName="Helvetica", spaceAfter=16, + alignment=TA_LEFT), + "section": ParagraphStyle("section", fontSize=13, textColor=EU_BLUE, + fontName="Helvetica-Bold", spaceBefore=10, + spaceAfter=6), + "body": ParagraphStyle("body", fontSize=10, textColor=DARK_GREY, + fontName="Helvetica", leading=16, + spaceAfter=6, alignment=TA_JUSTIFY), + "small": ParagraphStyle("small", fontSize=8, textColor=MID_GREY, + fontName="Helvetica", leading=12, + spaceAfter=4), + "label": ParagraphStyle("label", fontSize=9, textColor=MID_GREY, + fontName="Helvetica-Bold"), + "preferred": ParagraphStyle("preferred", fontSize=11, textColor=EU_BLUE, + fontName="Helvetica-Bold", spaceBefore=12, + spaceAfter=6), + "caption": ParagraphStyle("caption", fontSize=8, textColor=MID_GREY, + fontName="Helvetica-Oblique", spaceAfter=12, + spaceBefore=4, alignment=TA_CENTER), + } + + +def _score_color(score: float) -> colors.Color: + if score >= 1.5: return GREEN + elif score >= 0.5: return colors.HexColor("#2d7a2d") + elif score > -0.5: return DARK_GREY + elif score > -1.5: return AMBER + else: return RED + + +def _quality_color(flag: str) -> colors.Color: + if flag == "verified": return GREEN + elif flag in ("proxy", "stale"): return AMBER + else: return RED + + +def _on_page(canvas, doc): + canvas.saveState() + w, h = A4 + + # Header bar + canvas.setFillColor(EU_BLUE) + canvas.rect(0, h - 1.8*cm, w, 1.8*cm, fill=1, stroke=0) + canvas.setFillColor(EU_GOLD) + canvas.rect(0, h - 1.85*cm, w, 0.05*cm, fill=1, stroke=0) + canvas.setFillColor(WHITE) + canvas.setFont("Helvetica-Bold", 10) + canvas.drawString(1.5*cm, h - 1.1*cm, "EU Impact Assessment Brief") + canvas.setFont("Helvetica", 8) + canvas.drawRightString(w - 1.5*cm, h - 1.1*cm, + f"Strategy & Feedback Agent | {datetime.now().strftime('%d %b %Y')}") + + # Footer + canvas.setFillColor(MID_GREY) + canvas.setFont("Helvetica", 7) + canvas.drawString(1.5*cm, 0.8*cm, + "Generated by the Strategy & Feedback Agent — OpenPolicyStack. " + "All claims traceable to evidence payload.") + canvas.drawRightString(w - 1.5*cm, 0.8*cm, f"Page {doc.page}") + canvas.setStrokeColor(EU_BLUE) + canvas.setLineWidth(0.5) + canvas.line(1.5*cm, 1.2*cm, w - 1.5*cm, 1.2*cm) + + canvas.restoreState() + + +# ─── Page 1: Executive Summary ──────────────────────────────────────────────── + +# Section headers to detect in the LLM output +SECTION_HEADERS = { + "context and objective", + "preferred option and rationale", + "option comparison summary", + "evidence quality and assumptions", +} + +# Titles to completely skip — redundant with the PDF header +SKIP_TITLES = { + "eu quantum act options", + "impact assessment brief: eu quantum act options", + "impact assessment brief", + "eu quantum act impact assessment brief", + "eu quantum act impact assessment brief", +} + + +def _page1_executive_summary(s, req: BriefRequest, llm_brief: str) -> list: + story = [] + + preferred = next((os for os in req.option_scores if os.rank == 1), + req.option_scores[0] if req.option_scores else None) + + story.append(Spacer(1, 0.6*cm)) + story.append(Paragraph("Executive Summary", s["title"])) + story.append(Paragraph( + f"Scenario: {req.scenario_id} | " + f"Run ID: {req.run_id} | " + f"Objective: {req.objective or 'EU Quantum Act option comparison'}", + s["subtitle"] + )) + story.append(HRFlowable(width="100%", thickness=2, color=EU_BLUE, spaceAfter=8)) + + # Preferred option highlight box + if preferred: + highlight = Table([[ + Paragraph("PREFERRED OPTION", ParagraphStyle( + "ph", fontSize=8, textColor=WHITE, fontName="Helvetica-Bold")), + Paragraph(preferred.option_name, ParagraphStyle( + "pn", fontSize=11, textColor=WHITE, fontName="Helvetica-Bold")), + Paragraph(f"Score: {preferred.weighted_total:+.3f}", ParagraphStyle( + "ps", fontSize=11, textColor=EU_GOLD, + fontName="Helvetica-Bold", alignment=TA_CENTER)), + ]], colWidths=[3.5*cm, 11*cm, 3*cm]) + highlight.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, -1), EU_BLUE), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ])) + story.append(highlight) + story.append(Spacer(1, 0.2*cm)) + + # Parse LLM brief — clean markdown, detect sections + clean = _clean_markdown(llm_brief) + lines = clean.split("\n") + + for line in lines: + line = line.strip() + if not line: + continue + + if line.lower() in SKIP_TITLES: + continue # skip redundant LLM title + elif line.lower() in SECTION_HEADERS: + story.append(Paragraph(line, s["section"])) + elif line.startswith("This brief was generated"): + story.append(Spacer(1, 0.15*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, + color=MID_GREY, spaceAfter=6)) + story.append(Paragraph(line, s["small"])) + else: + story.append(Paragraph(line, s["body"])) + + # Option ranking table + story.append(Spacer(1, 0.15*cm)) + story.append(Paragraph("Option Ranking at a Glance", s["section"])) + + rank_data = [["Rank", "Policy Option", "Score", "Status"]] + for os in sorted(req.option_scores, key=lambda x: x.rank): + status_str = str(os.status).replace("OptionStatus.", "").capitalize() + rank_data.append([str(os.rank), os.option_name, + f"{os.weighted_total:+.3f}", status_str]) + + rank_table = Table(rank_data, colWidths=[1.5*cm, 10*cm, 2.5*cm, 3.5*cm]) + rank_table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), EU_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("TEXTCOLOR", (0, 1), (-1, -1), DARK_GREY), + ("BACKGROUND", (0, 1), (-1, 1), EU_BLUE_LIGHT), + ("ROWBACKGROUNDS",(0, 2), (-1, -1), [WHITE, LIGHT_GREY]), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CCCCCC")), + ("TOPPADDING", (0, 0), (-1, -1), 7), + ("BOTTOMPADDING", (0, 0), (-1, -1), 7), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("ALIGN", (0, 0), (0, -1), "CENTER"), + ("ALIGN", (2, 0), (2, -1), "CENTER"), + ])) + story.append(rank_table) + story.append(Paragraph( + "Table 1: Ranked policy options under default EU Better Regulation criterion weights.", + s["caption"] + )) + + return story + + +# ─── Page 2: Full MCDA Scoring Table ───────────────────────────────────────── + +def _page2_scoring_table(s, req: BriefRequest) -> list: + story = [PageBreak()] + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph("Full MCDA Scoring Table", s["title"])) + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph( + "Criteria scores for all policy options on the EU Better Regulation anchored scale " + "(-2 = strongly negative, 0 = neutral, +2 = strongly positive). " + "Default criterion weights: Economic 0.25, Competitiveness 0.25, Social 0.15, " + "Feasibility 0.15, Environmental 0.10, Coherence 0.10.", + s["body"] + )) + story.append(HRFlowable(width="100%", thickness=2, color=EU_BLUE, spaceAfter=8)) + + criteria = ["economic", "social", "environmental", + "competitiveness", "feasibility", "coherence"] + crit_labels = ["Econ", "Social", "Env", "Comp", "Feas", "Coh"] + + header = ["Option"] + crit_labels + ["Total", "Rank"] + table_data = [header] + options_sorted = sorted(req.option_scores, key=lambda x: x.rank) + + for os in options_sorted: + score_map = {cs.criterion: cs.score for cs in os.criteria_scores} + row = [os.option_name] + for c in criteria: + row.append(f"{score_map.get(c, 0.0):+.1f}") + row.append(f"{os.weighted_total:+.3f}") + row.append(str(os.rank)) + table_data.append(row) + + col_widths = [6.5*cm] + [1.5*cm]*6 + [2*cm, 1.2*cm] + score_table = Table(table_data, colWidths=col_widths) + + style_cmds = [ + ("BACKGROUND", (0, 0), (-1, 0), EU_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("TEXTCOLOR", (0, 1), (-1, -1), DARK_GREY), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CCCCCC")), + ("TOPPADDING", (0, 0), (-1, -1), 7), + ("BOTTOMPADDING", (0, 0), (-1, -1), 7), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("ALIGN", (1, 0), (-1, -1), "CENTER"), + ("BACKGROUND", (0, 1), (-1, 1), EU_BLUE_LIGHT), + ("ROWBACKGROUNDS",(0, 2), (-1, -1), [WHITE, LIGHT_GREY]), + ] + + for row_i, os in enumerate(options_sorted, start=1): + score_map = {cs.criterion: cs.score for cs in os.criteria_scores} + for col_i, c in enumerate(criteria, start=1): + sc = score_map.get(c, 0.0) + if sc >= 1.5: + style_cmds.append(("TEXTCOLOR", (col_i, row_i), (col_i, row_i), GREEN)) + style_cmds.append(("FONTNAME", (col_i, row_i), (col_i, row_i), "Helvetica-Bold")) + elif sc <= -1.0: + style_cmds.append(("TEXTCOLOR", (col_i, row_i), (col_i, row_i), RED)) + style_cmds.append(("FONTNAME", (col_i, row_i), (col_i, row_i), "Helvetica-Bold")) + + score_table.setStyle(TableStyle(style_cmds)) + story.append(score_table) + story.append(Paragraph( + "Table 2: Full MCDA criteria scores. Green = strongly positive (+1.5 or above), " + "Red = strongly negative (-1.0 or below).", + s["caption"] + )) + + story.append(Paragraph("Scoring Rationale by Option", s["section"])) + + for os in options_sorted: + status_str = str(os.status).replace("OptionStatus.", "").capitalize() + story.append(Paragraph( + f"{os.option_name} — Score: {os.weighted_total:+.3f} ({status_str})", + s["preferred"] + )) + for cs in os.criteria_scores: + ev = f"Evidence: {', '.join(cs.evidence_ids)}" if cs.evidence_ids \ + else f"Assumption: {cs.assumption or 'domain knowledge'}" + story.append(Paragraph( + f"{cs.criterion.capitalize()} ({cs.score:+.1f}): " + f"{cs.rationale} [{ev}]", + s["small"] + )) + story.append(Spacer(1, 0.2*cm)) + + return story + + +# ─── Page 3: Evidence & Assumptions ────────────────────────────────────────── + +def _page3_evidence(s, req: BriefRequest) -> list: + story = [PageBreak()] + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph("Evidence & Provenance", s["title"])) + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph( + "All indicators used in scoring are listed below with their source, " + "quality flag, and value. Quality flags: Verified = official source, " + "Proxy = estimated fallback, Assumption = no data available.", + s["body"] + )) + story.append(HRFlowable(width="100%", thickness=2, color=EU_BLUE, spaceAfter=8)) + + ev_data = [["Indicator", "Source", "Value", "Unit", "Quality"]] + for e in req.evidence: + ev_data.append([ + e.indicator_id, + e.source_ref, + str(e.value) if e.value is not None else "—", + e.unit or "—", + str(e.quality_flag).replace("QualityFlag.", "").capitalize(), + ]) + + ev_table = Table(ev_data, colWidths=[4.2*cm, 4.5*cm, 1.8*cm, 3.2*cm, 2.8*cm]) + ev_style = [ + ("BACKGROUND", (0, 0), (-1, 0), EU_BLUE), + ("TEXTCOLOR", (0, 0), (-1, 0), WHITE), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, -1), 8), + ("FONTNAME", (0, 1), (-1, -1), "Helvetica"), + ("TEXTCOLOR", (0, 1), (-1, -1), DARK_GREY), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CCCCCC")), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("ROWBACKGROUNDS",(0, 1), (-1, -1), [WHITE, LIGHT_GREY]), + ] + for row_i, e in enumerate(req.evidence, start=1): + flag = str(e.quality_flag).replace("QualityFlag.", "") + col = _quality_color(flag) + ev_style.append(("TEXTCOLOR", (4, row_i), (4, row_i), col)) + ev_style.append(("FONTNAME", (4, row_i), (4, row_i), "Helvetica-Bold")) + + ev_table.setStyle(TableStyle(ev_style)) + story.append(ev_table) + story.append(Paragraph( + "Table 3: Evidence payload. Every score in Table 2 references at least one " + "indicator from this table, or carries an explicit assumption tag.", + s["caption"] + )) + + story.append(Paragraph("Indicator Notes", s["section"])) + for e in req.evidence: + if e.note: + story.append(Paragraph( + f"{e.indicator_id}: {e.note}", s["small"] + )) + + if req.assumptions: + story.append(Paragraph("Explicit Assumptions", s["section"])) + story.append(Paragraph( + "The following criteria scores were assigned without direct indicator " + "evidence, explicitly flagged in accordance with the " + "'no evidence → no claim' rule.", + s["body"] + )) + for a in req.assumptions: + story.append(Paragraph(f"• {a}", s["small"])) + + story.append(Spacer(1, 0.2*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, color=MID_GREY, spaceAfter=8)) + story.append(Paragraph( + "Methodology: MCDA weighted additive scoring aligned with EU Better Regulation " + "framework. Criterion weights: Economic 0.25, Competitiveness 0.25, Social 0.15, " + "Feasibility 0.15, Environmental 0.10, Coherence 0.10. Scoring scale: -2 to +2. " + "LLM narrative generated by claude-sonnet-4-20250514 constrained to structured inputs only.", + s["small"] + )) + + return story + + +# ─── Master PDF generator ───────────────────────────────────────────────────── + +def generate_pdf_brief(req: BriefRequest, llm_brief: str) -> bytes: + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + leftMargin=1.5*cm, + rightMargin=1.5*cm, + topMargin=2.5*cm, + bottomMargin=2*cm, + ) + s = _styles() + story = [] + story += _page1_executive_summary(s, req, llm_brief) + story += _page2_scoring_table(s, req) + story += _page3_evidence(s, req) + + doc.build(story, onFirstPage=_on_page, onLaterPages=_on_page) + return buffer.getvalue() \ No newline at end of file diff --git a/modules/strategy-agent/app/schemas.py b/modules/strategy-agent/app/schemas.py new file mode 100644 index 0000000..23bde7e --- /dev/null +++ b/modules/strategy-agent/app/schemas.py @@ -0,0 +1,188 @@ +""" +schemas.py — Pydantic models for the IA-based Strategy & Feedback Agent. +All inputs and outputs are strictly typed. Every score must reference +at least one EvidenceItem or carry an explicit assumption tag. +""" + +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional +from enum import Enum + + +# ─── Enums ──────────────────────────────────────────────────────────────────── + +class QualityFlag(str, Enum): + VERIFIED = "verified" # value from official source, recent + PROXY = "proxy" # estimated or derived value + ASSUMPTION = "assumption" # no data found, explicit assumption used + STALE = "stale" # data older than 3 years + + +class SourceType(str, Enum): + EUROSTAT = "eurostat" + CORDIS = "cordis" + DERIVED = "derived" + MANUAL = "manual" + + +class IACriterion(str, Enum): + ECONOMIC = "economic" + SOCIAL = "social" + ENVIRONMENTAL = "environmental" + COMPETITIVENESS = "competitiveness" + FEASIBILITY = "feasibility" + COHERENCE = "coherence" + + +class OptionStatus(str, Enum): + PREFERRED = "preferred" + ALTERNATIVE = "alternative" + BASELINE = "baseline" + REJECTED = "rejected" + + +# ─── Evidence & Provenance ──────────────────────────────────────────────────── + +class EvidenceItem(BaseModel): + indicator_id: str + source_type: SourceType + source_ref: str # e.g. "eurostat:rd_e_gerdtot" + field_path: str # e.g. "EU27_2020.2022" + value: Optional[float] = None + unit: Optional[str] = None + quality_flag: QualityFlag = QualityFlag.VERIFIED + note: Optional[str] = None + + +# ─── Indicator Catalogue Entry ──────────────────────────────────────────────── + +class IndicatorValue(BaseModel): + indicator_id: str + name: str + value: float + unit: str + source_type: SourceType + source_ref: str + quality_flag: QualityFlag = QualityFlag.VERIFIED + year: Optional[int] = None + note: Optional[str] = None + + +# ─── IA Blueprint ───────────────────────────────────────────────────────────── + +class IAObjective(BaseModel): + id: str + level: str # "general" or "specific" + description: str + indicators: List[str] = Field(default_factory=list) + + +class PolicyOption(BaseModel): + id: str + name: str + description: str + status: OptionStatus = OptionStatus.ALTERNATIVE + + +class IABlueprint(BaseModel): + legislation_name: str # e.g. "EU Quantum Act" + problem_statement: str + baseline_description: str + objectives: List[IAObjective] = Field(default_factory=list) + policy_options: List[PolicyOption] = Field(default_factory=list) + monitoring_indicators: List[str] = Field(default_factory=list) + + +# ─── Scoring ────────────────────────────────────────────────────────────────── + +class CriteriaScore(BaseModel): + criterion: str + score: float # anchored scale: -2 to +2 + rationale: str + evidence_ids: List[str] = Field(default_factory=list) # ref indicator_ids + assumption: Optional[str] = None # required if no evidence + + +class OptionScore(BaseModel): + option_id: str + option_name: str + criteria_scores: List[CriteriaScore] + weighted_total: float + rank: int + status: OptionStatus + + +# ─── Sensitivity Analysis ───────────────────────────────────────────────────── + +class SensitivityResult(BaseModel): + scenario_label: str # e.g. "economic_priority" + weights_used: Dict[str, float] + option_rankings: List[Dict[str, Any]] # [{option_id, rank, score}] + ranking_stable: bool # True if top option unchanged + top_option_id: str + + +# ─── Drivers ────────────────────────────────────────────────────────────────── + +class Driver(BaseModel): + criterion: str + direction: str # "positive" | "negative" | "neutral" + contribution: float # absolute weighted contribution + source_ref: Optional[str] = None + + +# ─── Request Models ─────────────────────────────────────────────────────────── + +class AssessRequest(BaseModel): + run_id: str + scenario_id: str + blueprint: IABlueprint + indicators: List[IndicatorValue] = Field(default_factory=list) + constraints: Dict[str, Any] = Field(default_factory=dict) + + +class SensitivityRequest(BaseModel): + run_id: str + scenario_id: str + option_scores: List[OptionScore] + weight_grid: Optional[List[Dict[str, float]]] = None # custom weight scenarios + constraints: Dict[str, Any] = Field(default_factory=dict) + + +class BriefRequest(BaseModel): + run_id: str + scenario_id: str + option_scores: List[OptionScore] + drivers: List[Driver] = Field(default_factory=list) + assumptions: List[str] = Field(default_factory=list) + evidence: List[EvidenceItem] = Field(default_factory=list) + objective: Optional[str] = None + + +# ─── Response Models ────────────────────────────────────────────────────────── + +class AssessResponse(BaseModel): + run_id: str + scenario_id: str + option_scores: List[OptionScore] + drivers: List[Driver] + assumptions: List[str] + evidence: List[EvidenceItem] + + +class SensitivityResponse(BaseModel): + run_id: str + scenario_id: str + results: List[SensitivityResult] + stable: bool # True if top option consistent across all scenarios + summary: str + + +class BriefResponse(BaseModel): + run_id: str + scenario_id: str + brief_markdown: str + option_scores: List[OptionScore] + drivers: List[Driver] + assumptions: List[str] + evidence: List[EvidenceItem] diff --git a/modules/strategy-agent/app/scoring.py b/modules/strategy-agent/app/scoring.py new file mode 100644 index 0000000..eb21b3d --- /dev/null +++ b/modules/strategy-agent/app/scoring.py @@ -0,0 +1,202 @@ +""" +scoring.py — MCDA scoring engine for the Strategy & Feedback Agent. + +Scoring rules are threshold-driven, using live indicator values to produce +scenario-sensitive scores. Rankings change across baseline, adverse, and +recovery scenarios as intended by the EU Better Regulation IA framework. + +Scoring scale: -2 (strongly negative) to +2 (strongly positive) +Default weights: economic 0.25, competitiveness 0.25, social 0.15, + feasibility 0.15, environmental 0.10, coherence 0.10 +""" + +from app.schemas import IABlueprint, IndicatorValue, OptionScore, CriteriaScore +from app.config import DEFAULT_WEIGHTS, SCORE_MIN, SCORE_MAX + + +def _clamp(score: float) -> float: + return max(SCORE_MIN, min(SCORE_MAX, score)) + + +def _get_indicator(indicators: list, indicator_id: str): + for ind in indicators: + if ind.indicator_id == indicator_id: + return ind.value + return None + + +def _score_option(option_id: str, indicators: list) -> dict: + """ + Return a dict mapping criterion -> (score, rationale, evidence_ids, assumption) + using live indicator values and threshold logic to produce scenario-sensitive scores. + """ + gerd = _get_indicator(indicators, "gerd_pct_gdp") or 2.22 + rd_personnel = _get_indicator(indicators, "rd_personnel_pct") or 1.43 + hightech = _get_indicator(indicators, "hightech_employment_pct") or 4.8 + projects = _get_indicator(indicators, "cordis_quantum_projects") or 320 + funding = _get_indicator(indicators, "cordis_quantum_funding_meur") or 1200 + + # Threshold flags + gerd_declining = gerd < 2.0 + gerd_recovering = gerd > 2.35 + funding_low = funding < 1000 + funding_high = funding > 1400 + projects_low = projects < 300 + hightech_low = hightech < 4.5 + hightech_high = hightech > 5.0 + + # ── opt-0: Baseline — no new intervention ──────────────────────────────── + if option_id == "opt-0": + econ_score = -1.0 if gerd_declining else 0.0 + econ_rat = ( + f"No new spending; GERD declining to {gerd:.2f}% GDP risks further contraction without intervention." + if gerd_declining else + f"No new spending; GERD at {gerd:.2f}% GDP continues on current trajectory." + ) + soc_score = -1.0 if hightech_low else 0.0 + soc_rat = ( + f"R&D personnel at {rd_personnel:.2f}% active pop; declining high-tech employment ({hightech:.1f}%) signals structural weakening." + if hightech_low else + f"R&D personnel at {rd_personnel:.2f}% active pop; no structural change expected." + ) + comp_score = -2.0 if (funding_low or projects_low) else -1.0 + comp_rat = ( + f"With only {int(projects)} quantum projects and {funding:.0f}M EUR funded, absence of framework accelerates falling behind US/China." + if (funding_low or projects_low) else + f"With {int(projects)} quantum projects and {funding:.0f}M EUR funded, absence of dedicated framework risks falling behind US/China." + ) + return { + "economic": (econ_score, econ_rat, ["gerd_pct_gdp"], None), + "social": (soc_score, soc_rat, ["rd_personnel_pct"], None), + "environmental": (0.0, "No regulatory change; environmental impact remains as-is.", [], "No direct indicator available; score based on domain knowledge."), + "competitiveness": (comp_score, comp_rat, ["cordis_quantum_projects", "cordis_quantum_funding_meur"], None), + "feasibility": (2.0, "Baseline requires no implementation effort.", [], "No direct indicator available; score based on domain knowledge."), + "coherence": (0.0, "Consistent with existing EU policy but misses Quantum Flagship ambition.", [], "No direct indicator available; score based on domain knowledge."), + } + + # ── opt-1: Coordination only — voluntary frameworks ────────────────────── + if option_id == "opt-1": + econ_score = 0.0 if gerd_declining else 1.0 + econ_rat = ( + f"Soft coordination insufficient to arrest GERD decline from {gerd:.2f}% GDP; voluntary measures lack enforcement." + if gerd_declining else + f"Soft coordination expected to modestly increase GERD above {gerd:.2f}% GDP via improved funding alignment." + ) + comp_score = 0.0 if projects_low else 1.0 + comp_rat = ( + f"Only {int(projects)} active CORDIS quantum projects; voluntary framework improves visibility but lacks enforcement under constrained conditions." + if projects_low else + f"Aligning {int(projects)} existing CORDIS quantum projects under a voluntary framework improves visibility but lacks enforcement." + ) + return { + "economic": (econ_score, econ_rat, ["gerd_pct_gdp"], None), + "social": (1.0, f"Voluntary talent pipelines could raise R&D personnel (currently {rd_personnel:.2f}% active pop) through mobility schemes.", ["rd_personnel_pct"], None), + "environmental": (0.0, "Coordination option has negligible direct environmental impact.", [], "No direct indicator available; score based on domain knowledge."), + "competitiveness": (comp_score, comp_rat, ["cordis_quantum_projects"], None), + "feasibility": (2.0, "Voluntary framework is low-risk and fast to implement.", [], "No direct indicator available; score based on domain knowledge."), + "coherence": (1.0, "Consistent with existing EU open-coordination method.", [], "No direct indicator available; score based on domain knowledge."), + } + + # ── opt-2: Light regulation — standardisation and certification ─────────── + if option_id == "opt-2": + # Economic: strongest when investment base is growing + econ_score = 2.0 if (gerd_recovering and funding_high) else 1.0 + econ_rat = ( + f"Standardisation unlocks full value of growing {funding:.0f}M EUR quantum investment base; GERD at {gerd:.2f}% provides strong fiscal foundation." + if (gerd_recovering and funding_high) else + f"Standardisation reduces fragmentation costs; builds on {funding:.0f}M EUR quantum investment base." + ) + # Social: strongest when labour pool is large + soc_score = 2.0 if hightech_high else 1.0 + soc_rat = ( + f"Certification requirements create skilled workforce demand; strong high-tech employment at {hightech:.1f}% provides deep labour base." + if hightech_high else + f"Certification requirements create skilled workforce demand; high-tech employment at {hightech:.1f}% provides labour base." + ) + # Coherence: weaker under adverse as voluntary standards lose enforceability + coh_score = 1.0 if gerd_declining else 2.0 + coh_rat = ( + "Standards-based approach less enforceable under fiscal pressure; coherence with Digital Decade targets constrained." + if gerd_declining else + "Fully coherent with NIS2, Cyber Resilience Act, and Digital Decade targets." + ) + return { + "economic": (econ_score, econ_rat, ["gerd_pct_gdp", "cordis_quantum_funding_meur"], None), + "social": (soc_score, soc_rat, ["hightech_employment_pct"], None), + "environmental": (1.0, "Energy-efficiency standards for quantum hardware reduce footprint.", [], "No direct indicator available; score based on domain knowledge."), + "competitiveness": (2.0, f"Mandatory standards position EU as global norm-setter in quantum, leveraging {int(projects)} active projects.", ["cordis_quantum_projects", "cordis_quantum_funding_meur"], None), + "feasibility": (1.0, "Manageable compliance burden; existing ETSI/CEN structures can absorb.", [], "No direct indicator available; score based on domain knowledge."), + "coherence": (coh_score, coh_rat, [], "No direct indicator available; score based on domain knowledge."), + } + + # ── opt-3: Comprehensive regulation — EU Quantum Agency ────────────────── + if option_id == "opt-3": + econ_rat = ( + f"Dedicated agency and mandatory procurement rules provide strongest counter-cyclical stimulus when GERD is declining to {gerd:.2f}% GDP." + if gerd_declining else + f"Dedicated agency and procurement rules expected to significantly increase GERD above {gerd:.2f}% GDP in quantum-specific sectors." + ) + soc_rat = ( + f"Mandatory training and certification most critical when high-tech employment is declining ({hightech:.1f}%); R&D personnel at {rd_personnel:.2f}% needs structural support." + if hightech_low else + f"Mandatory training and certification boosts skilled employment; R&D personnel ratio ({rd_personnel:.2f}%) projected to rise." + ) + return { + "economic": (2.0, econ_rat, ["gerd_pct_gdp", "cordis_quantum_funding_meur"], None), + "social": (2.0, soc_rat, ["rd_personnel_pct", "hightech_employment_pct"], None), + "environmental": (1.0, "Comprehensive framework includes sustainability criteria for quantum hardware.", [], "No direct indicator available; score based on domain knowledge."), + "competitiveness": (2.0, f"Strongest intervention; {int(projects)} quantum projects brought under single governance with mandatory certification and EU procurement preference.", ["cordis_quantum_projects", "cordis_quantum_funding_meur"], None), + "feasibility": (-1.0, "High implementation complexity; agency setup requires 2-4 years and significant institutional coordination.", [], "No direct indicator available; score based on domain knowledge."), + "coherence": (1.0, "Coherent with EU strategic autonomy agenda but creates new regulatory layer.", [], "No direct indicator available; score based on domain knowledge."), + } + + return {c: (0.0, "Unknown option.", [], "No indicator available.") + for c in ["economic", "social", "environmental", "competitiveness", "feasibility", "coherence"]} + + +def score_options(blueprint: IABlueprint, + indicators: list[IndicatorValue]) -> list[OptionScore]: + """Score all policy options and return a ranked list of OptionScore objects.""" + weights = DEFAULT_WEIGHTS + results = [] + + for option in blueprint.policy_options: + raw = _score_option(option.id, indicators) + + criteria_scores = [] + weighted_total = 0.0 + + for criterion, (score, rationale, evidence_ids, assumption) in raw.items(): + clamped = _clamp(score) + weighted_total += clamped * weights.get(criterion, 0.0) + criteria_scores.append(CriteriaScore( + criterion = criterion, + score = clamped, + rationale = rationale, + evidence_ids = evidence_ids, + assumption = assumption, + )) + + results.append(OptionScore( + option_id = option.id, + option_name = option.name, + criteria_scores = criteria_scores, + weighted_total = round(weighted_total, 3), + rank = 0, + status = "alternative", + )) + + results.sort(key=lambda x: ( + x.weighted_total, + sum(cs.score for cs in x.criteria_scores if cs.criterion in ("economic", "social")) +), reverse=True) + + for i, opt in enumerate(results): + opt.rank = i + 1 + opt.status = ( + "preferred" if i == 0 else + "baseline" if opt.option_id == "opt-0" else + "alternative" + ) + + return results \ No newline at end of file diff --git a/modules/strategy-agent/app/sensitivity.py b/modules/strategy-agent/app/sensitivity.py new file mode 100644 index 0000000..4a0a52f --- /dev/null +++ b/modules/strategy-agent/app/sensitivity.py @@ -0,0 +1,96 @@ +""" +sensitivity.py — Sensitivity analysis on MCDA criterion weights. + +Tests how option rankings change across different weight scenarios. +Returns a SensitivityResponse with per-scenario rankings and a +stability summary indicating whether the top option is robust. +""" + +from typing import List, Dict, Optional +from app.schemas import ( + OptionScore, SensitivityResult, SensitivityResponse, IACriterion +) +from app.config import SENSITIVITY_WEIGHT_GRID + + +def _rescore( + option_scores: List[OptionScore], + weights: Dict[str, float], +) -> List[Dict]: + """ + Re-compute weighted totals for each option under new weights. + Returns list of {option_id, option_name, score, rank} sorted by score. + """ + rescored = [] + for os in option_scores: + total = 0.0 + for cs in os.criteria_scores: + w = weights.get(cs.criterion, 1 / len(IACriterion)) + total += w * cs.score + rescored.append({ + "option_id": os.option_id, + "option_name": os.option_name, + "score": round(total, 4), + }) + + rescored.sort(key=lambda x: x["score"], reverse=True) + for rank, item in enumerate(rescored, start=1): + item["rank"] = rank + return rescored + + +def run_sensitivity( + option_scores: List[OptionScore], + weight_grid: Optional[List[Dict[str, float]]] = None, +) -> SensitivityResponse: + """ + Run sensitivity analysis across the weight grid. + If weight_grid is not provided, uses the default SENSITIVITY_WEIGHT_GRID. + """ + grid = weight_grid or SENSITIVITY_WEIGHT_GRID + + # Identify baseline top option (rank 1 in original scoring) + baseline_top = next( + (os.option_id for os in option_scores if os.rank == 1), None + ) + + results: List[SensitivityResult] = [] + stable_count = 0 + + for scenario in grid: + label = scenario.get("label", "unnamed") + weights = {k: v for k, v in scenario.items() if k != "label"} + + rankings = _rescore(option_scores, weights) + top_in_scenario = rankings[0]["option_id"] if rankings else None + ranking_stable = (top_in_scenario == baseline_top) + + if ranking_stable: + stable_count += 1 + + results.append(SensitivityResult( + scenario_label=label, + weights_used=weights, + option_rankings=rankings, + ranking_stable=ranking_stable, + top_option_id=top_in_scenario or "", + )) + + overall_stable = stable_count == len(results) + stable_pct = round(100 * stable_count / len(results)) if results else 0 + + summary = ( + f"Top-ranked option is consistent across {stable_count}/{len(results)} " + f"weight scenarios ({stable_pct}%). " + + ("Ranking is robust to weight variation." if overall_stable + else "Ranking is sensitive to criterion weighting — " + "review feasibility and competitiveness trade-offs.") + ) + + return SensitivityResponse( + run_id="", # filled in main.py + scenario_id="", # filled in main.py + results=results, + stable=overall_stable, + summary=summary, + ) \ No newline at end of file diff --git a/modules/strategy-agent/app/utils.py b/modules/strategy-agent/app/utils.py new file mode 100644 index 0000000..6bbb2c5 --- /dev/null +++ b/modules/strategy-agent/app/utils.py @@ -0,0 +1,30 @@ +""" +utils.py — Shared utility functions. +""" + + +def normalize_score(value: float, min_val: float = -2.0, max_val: float = 2.0) -> float: + """Normalize a score to the [0, 1] range for display purposes.""" + if max_val == min_val: + return 0.5 + return (value - min_val) / (max_val - min_val) + + +def check_bounds(value: float, min_val: float, max_val: float) -> bool: + """Return True if value is within [min_val, max_val].""" + return min_val <= value <= max_val + + +def safe_float(value) -> float: + try: + return float(value) + except Exception: + return 0.0 + + +def direction_from_value(value: float) -> str: + if value > 0: + return "positive" + elif value < 0: + return "negative" + return "neutral" \ No newline at end of file diff --git a/modules/strategy-agent/examples/assess_request_adverse.json b/modules/strategy-agent/examples/assess_request_adverse.json new file mode 100644 index 0000000..5519d36 --- /dev/null +++ b/modules/strategy-agent/examples/assess_request_adverse.json @@ -0,0 +1,124 @@ +{ + "run_id": "quantum-act-002", + "scenario_id": "adverse_v1", + "blueprint": { + "legislation_name": "EU Quantum Act", + "problem_statement": "The EU lacks a dedicated regulatory framework for quantum technologies, resulting in fragmented investment, unclear standardisation, and insufficient coordination to compete with US and Chinese quantum programmes.", + "baseline_description": "Adverse scenario: EU GERD declining due to fiscal consolidation pressures, high-tech employment contracting, and US CHIPS-equivalent quantum investment accelerating. Urgency for intervention is higher.", + "objectives": [ + { + "id": "go-1", + "level": "general", + "description": "Strengthen EU strategic autonomy in quantum technologies under adverse conditions", + "indicators": ["gerd_pct_gdp", "cordis_quantum_projects"] + }, + { + "id": "so-1", + "level": "specific", + "description": "Arrest declining R&D investment trend with targeted quantum stimulus", + "indicators": ["gerd_pct_gdp", "cordis_quantum_funding_meur"] + }, + { + "id": "so-2", + "level": "specific", + "description": "Protect high-tech employment base during economic contraction", + "indicators": ["hightech_employment_pct", "rd_personnel_pct"] + } + ], + "policy_options": [ + { + "id": "opt-0", + "name": "Baseline — no new intervention", + "description": "Maintain current trajectory under adverse conditions. No new EU-level quantum regulation.", + "status": "baseline" + }, + { + "id": "opt-1", + "name": "Coordination only — voluntary frameworks", + "description": "Soft coordination with voluntary funding alignment under fiscal pressure.", + "status": "alternative" + }, + { + "id": "opt-2", + "name": "Light regulation — standardisation and certification", + "description": "Mandatory EU quantum standards and certification with stimulus carve-out for compliant firms.", + "status": "alternative" + }, + { + "id": "opt-3", + "name": "Comprehensive regulation — EU Quantum Agency", + "description": "Dedicated EU Quantum Agency with emergency investment powers and mandatory procurement rules.", + "status": "alternative" + } + ], + "monitoring_indicators": [ + "gerd_pct_gdp", + "cordis_quantum_projects", + "cordis_quantum_funding_meur", + "rd_personnel_pct", + "hightech_employment_pct" + ] + }, + "indicators": [ + { + "indicator_id": "gerd_pct_gdp", + "name": "EU GERD as % of GDP", + "value": 1.95, + "unit": "% of GDP", + "source_type": "eurostat", + "source_ref": "eurostat:rd_e_gerdtot", + "quality_flag": "proxy", + "year": 2024, + "note": "Adverse scenario: projected decline under fiscal consolidation (proxy estimate)" + }, + { + "indicator_id": "rd_personnel_pct", + "name": "EU R&D personnel as % of active population", + "value": 1.28, + "unit": "% active population", + "source_type": "eurostat", + "source_ref": "eurostat:rd_p_persocc", + "quality_flag": "proxy", + "year": 2024, + "note": "Adverse scenario: contraction in R&D headcount (proxy estimate)" + }, + { + "indicator_id": "hightech_employment_pct", + "name": "EU high-tech employment as % of total employment", + "value": 4.2, + "unit": "% of total employment", + "source_type": "eurostat", + "source_ref": "eurostat:htec_emp_nat2", + "quality_flag": "proxy", + "year": 2024, + "note": "Adverse scenario: high-tech employment contracting (proxy estimate)" + }, + { + "indicator_id": "cordis_quantum_projects", + "name": "EU-funded quantum research projects (CORDIS)", + "value": 280.0, + "unit": "projects", + "source_type": "cordis", + "source_ref": "cordis:search?q=quantum", + "quality_flag": "proxy", + "year": 2024, + "note": "Adverse scenario: fewer new project approvals due to budget pressure" + }, + { + "indicator_id": "cordis_quantum_funding_meur", + "name": "EU quantum project funding — sample total (M EUR)", + "value": 950.0, + "unit": "M EUR", + "source_type": "cordis", + "source_ref": "cordis:search?q=quantum", + "quality_flag": "proxy", + "year": 2024, + "note": "Adverse scenario: reduced EC quantum budget allocation (proxy estimate)" + } + ], + "constraints": { + "geo": "EU27_2020", + "fetch_live_data": false, + "note": "Indicators provided explicitly for adverse scenario — no live fetch" + } +} diff --git a/modules/strategy-agent/examples/assess_request_baseline.json b/modules/strategy-agent/examples/assess_request_baseline.json new file mode 100644 index 0000000..123cca9 --- /dev/null +++ b/modules/strategy-agent/examples/assess_request_baseline.json @@ -0,0 +1,74 @@ +{ + "run_id": "quantum-act-001", + "scenario_id": "baseline_v1", + "blueprint": { + "legislation_name": "EU Quantum Act", + "problem_statement": "The EU lacks a dedicated regulatory framework for quantum technologies, resulting in fragmented investment, unclear standardisation, and insufficient coordination to compete with US and Chinese quantum programmes.", + "baseline_description": "Current state: EU Quantum Flagship running, ~320 CORDIS-funded quantum projects, GERD at ~2.22% GDP. No dedicated quantum-specific regulation or certification framework exists.", + "objectives": [ + { + "id": "go-1", + "level": "general", + "description": "Strengthen EU strategic autonomy in quantum technologies", + "indicators": ["gerd_pct_gdp", "cordis_quantum_projects"] + }, + { + "id": "so-1", + "level": "specific", + "description": "Increase coordinated EU public investment in quantum R&D", + "indicators": ["gerd_pct_gdp", "cordis_quantum_funding_meur"] + }, + { + "id": "so-2", + "level": "specific", + "description": "Establish EU-wide quantum certification and standardisation", + "indicators": ["hightech_employment_pct"] + }, + { + "id": "so-3", + "level": "specific", + "description": "Build a skilled quantum workforce across Member States", + "indicators": ["rd_personnel_pct", "hightech_employment_pct"] + } + ], + "policy_options": [ + { + "id": "opt-0", + "name": "Baseline — no new intervention", + "description": "Maintain current trajectory. No new EU-level quantum regulation. Quantum Flagship continues under existing rules.", + "status": "baseline" + }, + { + "id": "opt-1", + "name": "Coordination only — voluntary frameworks", + "description": "Soft coordination: voluntary funding alignment, talent mobility schemes, and a non-binding quantum roadmap. No mandatory requirements.", + "status": "alternative" + }, + { + "id": "opt-2", + "name": "Light regulation — standardisation and certification", + "description": "Mandatory EU quantum standards (via ETSI/CEN), security certification for quantum communications, and energy-efficiency requirements for quantum hardware.", + "status": "alternative" + }, + { + "id": "opt-3", + "name": "Comprehensive regulation — EU Quantum Agency", + "description": "Dedicated EU Quantum Agency, mandatory certification for all quantum systems used in critical infrastructure, EU procurement preference, and binding investment targets.", + "status": "alternative" + } + ], + "monitoring_indicators": [ + "gerd_pct_gdp", + "cordis_quantum_projects", + "cordis_quantum_funding_meur", + "rd_personnel_pct", + "hightech_employment_pct" + ] + }, + "indicators": [], + "constraints": { + "geo": "EU27_2020", + "fetch_live_data": true, + "note": "Empty indicators list triggers live Eurostat + CORDIS fetch" + } +} diff --git a/modules/strategy-agent/examples/assess_request_recovery.json b/modules/strategy-agent/examples/assess_request_recovery.json new file mode 100644 index 0000000..a20bb04 --- /dev/null +++ b/modules/strategy-agent/examples/assess_request_recovery.json @@ -0,0 +1,124 @@ +{ + "run_id": "quantum-act-003", + "scenario_id": "recovery_v1", + "blueprint": { + "legislation_name": "EU Quantum Act", + "problem_statement": "The EU lacks a dedicated regulatory framework for quantum technologies, resulting in fragmented investment, unclear standardisation, and insufficient coordination to compete with US and Chinese quantum programmes.", + "baseline_description": "Recovery scenario: EU GERD recovering post-shock, Horizon Europe quantum calls oversubscribed, Member States increasing national quantum budgets. Window of opportunity to lock in governance before market matures.", + "objectives": [ + { + "id": "go-1", + "level": "general", + "description": "Capitalise on recovery momentum to establish durable quantum governance", + "indicators": ["gerd_pct_gdp", "cordis_quantum_projects"] + }, + { + "id": "so-1", + "level": "specific", + "description": "Lock in investment trajectory with binding governance before market fragmentation", + "indicators": ["gerd_pct_gdp", "cordis_quantum_funding_meur"] + }, + { + "id": "so-2", + "level": "specific", + "description": "Scale quantum workforce rapidly during recovery window", + "indicators": ["rd_personnel_pct", "hightech_employment_pct"] + } + ], + "policy_options": [ + { + "id": "opt-0", + "name": "Baseline — no new intervention", + "description": "Allow recovery to proceed organically without new regulation.", + "status": "baseline" + }, + { + "id": "opt-1", + "name": "Coordination only — voluntary frameworks", + "description": "Use recovery momentum for voluntary coordination and roadmap alignment.", + "status": "alternative" + }, + { + "id": "opt-2", + "name": "Light regulation — standardisation and certification", + "description": "Establish standards and certification to channel recovery investment into interoperable EU quantum infrastructure.", + "status": "alternative" + }, + { + "id": "opt-3", + "name": "Comprehensive regulation — EU Quantum Agency", + "description": "Leverage recovery window to establish EU Quantum Agency with full governance powers.", + "status": "alternative" + } + ], + "monitoring_indicators": [ + "gerd_pct_gdp", + "cordis_quantum_projects", + "cordis_quantum_funding_meur", + "rd_personnel_pct", + "hightech_employment_pct" + ] + }, + "indicators": [ + { + "indicator_id": "gerd_pct_gdp", + "name": "EU GERD as % of GDP", + "value": 2.45, + "unit": "% of GDP", + "source_type": "eurostat", + "source_ref": "eurostat:rd_e_gerdtot", + "quality_flag": "proxy", + "year": 2025, + "note": "Recovery scenario: GERD recovering above 3% target trajectory (proxy estimate)" + }, + { + "indicator_id": "rd_personnel_pct", + "name": "EU R&D personnel as % of active population", + "value": 1.62, + "unit": "% active population", + "source_type": "eurostat", + "source_ref": "eurostat:rd_p_persocc", + "quality_flag": "proxy", + "year": 2025, + "note": "Recovery scenario: R&D headcount expanding with new Horizon Europe cohort (proxy estimate)" + }, + { + "indicator_id": "hightech_employment_pct", + "name": "EU high-tech employment as % of total employment", + "value": 5.3, + "unit": "% of total employment", + "source_type": "eurostat", + "source_ref": "eurostat:htec_emp_nat2", + "quality_flag": "proxy", + "year": 2025, + "note": "Recovery scenario: high-tech employment growing above trend (proxy estimate)" + }, + { + "indicator_id": "cordis_quantum_projects", + "name": "EU-funded quantum research projects (CORDIS)", + "value": 410.0, + "unit": "projects", + "source_type": "cordis", + "source_ref": "cordis:search?q=quantum", + "quality_flag": "proxy", + "year": 2025, + "note": "Recovery scenario: oversubscribed Horizon Europe quantum calls (proxy estimate)" + }, + { + "indicator_id": "cordis_quantum_funding_meur", + "name": "EU quantum project funding — sample total (M EUR)", + "value": 1650.0, + "unit": "M EUR", + "source_type": "cordis", + "source_ref": "cordis:search?q=quantum", + "quality_flag": "proxy", + "year": 2025, + "note": "Recovery scenario: increased EC quantum budget allocation (proxy estimate)" + } + ], + "constraints": { + "geo": "EU27_2020", + "fetch_live_data": false, + "note": "Indicators provided explicitly for recovery scenario — no live fetch" + } +} diff --git a/modules/strategy-agent/examples/assess_response_adverse.json b/modules/strategy-agent/examples/assess_response_adverse.json new file mode 100644 index 0000000..f160df4 --- /dev/null +++ b/modules/strategy-agent/examples/assess_response_adverse.json @@ -0,0 +1 @@ +{"run_id":"quantum-act-002","scenario_id":"adverse_v1","option_scores":[{"option_id":"opt-3","option_name":"Comprehensive regulation — EU Quantum Agency","criteria_scores":[{"criterion":"economic","score":2.0,"rationale":"Dedicated agency and mandatory procurement rules provide strongest counter-cyclical stimulus when GERD is declining to 1.95% GDP.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":2.0,"rationale":"Mandatory training and certification most critical when high-tech employment is declining (4.2%); R&D personnel at 1.28% needs structural support.","evidence_ids":["rd_personnel_pct","hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Comprehensive framework includes sustainability criteria for quantum hardware.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Strongest intervention; 280 quantum projects brought under single governance with mandatory certification and EU procurement preference.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":-1.0,"rationale":"High implementation complexity; agency setup requires 2-4 years and significant institutional coordination.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Coherent with EU strategic autonomy agenda but creates new regulatory layer.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.35,"rank":1,"status":"preferred"},{"option_id":"opt-2","option_name":"Light regulation — standardisation and certification","criteria_scores":[{"criterion":"economic","score":1.0,"rationale":"Standardisation reduces fragmentation costs; builds on 950M EUR quantum investment base.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":1.0,"rationale":"Certification requirements create skilled workforce demand; high-tech employment at 4.2% provides labour base.","evidence_ids":["hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Energy-efficiency standards for quantum hardware reduce footprint.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Mandatory standards position EU as global norm-setter in quantum, leveraging 280 active projects.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":1.0,"rationale":"Manageable compliance burden; existing ETSI/CEN structures can absorb.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Standards-based approach less enforceable under fiscal pressure; coherence with Digital Decade targets constrained.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.25,"rank":2,"status":"alternative"},{"option_id":"opt-1","option_name":"Coordination only — voluntary frameworks","criteria_scores":[{"criterion":"economic","score":0.0,"rationale":"Soft coordination insufficient to arrest GERD decline from 1.95% GDP; voluntary measures lack enforcement.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":1.0,"rationale":"Voluntary talent pipelines could raise R&D personnel (currently 1.28% active pop) through mobility schemes.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"Coordination option has negligible direct environmental impact.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":0.0,"rationale":"Only 280 active CORDIS quantum projects; voluntary framework improves visibility but lacks enforcement under constrained conditions.","evidence_ids":["cordis_quantum_projects"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Voluntary framework is low-risk and fast to implement.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Consistent with existing EU open-coordination method.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":0.55,"rank":3,"status":"alternative"},{"option_id":"opt-0","option_name":"Baseline — no new intervention","criteria_scores":[{"criterion":"economic","score":-1.0,"rationale":"No new spending; GERD declining to 1.95% GDP risks further contraction without intervention.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":-1.0,"rationale":"R&D personnel at 1.28% active pop; declining high-tech employment (4.2%) signals structural weakening.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"No regulatory change; environmental impact remains as-is.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":-2.0,"rationale":"With only 280 quantum projects and 950M EUR funded, absence of framework accelerates falling behind US/China.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Baseline requires no implementation effort.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":0.0,"rationale":"Consistent with existing EU policy but misses Quantum Flagship ambition.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":-0.6,"rank":4,"status":"baseline"}],"drivers":[{"criterion":"economic","direction":"positive","contribution":0.5,"source_ref":"gerd_pct_gdp, cordis_quantum_funding_meur"},{"criterion":"competitiveness","direction":"positive","contribution":0.5,"source_ref":"cordis_quantum_projects, cordis_quantum_funding_meur"},{"criterion":"social","direction":"positive","contribution":0.3,"source_ref":"rd_personnel_pct, hightech_employment_pct"}],"assumptions":["[Comprehensive regulation — EU Quantum Agency / environmental] No direct indicator available; score based on domain knowledge."],"evidence":[{"indicator_id":"cordis_quantum_funding_meur","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_funding_meur.2024","value":950.0,"unit":"M EUR","quality_flag":"proxy","note":"Adverse scenario: reduced EC quantum budget allocation (proxy estimate)"},{"indicator_id":"cordis_quantum_projects","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_projects.2024","value":280.0,"unit":"projects","quality_flag":"proxy","note":"Adverse scenario: fewer new project approvals due to budget pressure"},{"indicator_id":"gerd_pct_gdp","source_type":"eurostat","source_ref":"eurostat:rd_e_gerdtot","field_path":"gerd_pct_gdp.2024","value":1.95,"unit":"% of GDP","quality_flag":"proxy","note":"Adverse scenario: projected decline under fiscal consolidation (proxy estimate)"},{"indicator_id":"hightech_employment_pct","source_type":"eurostat","source_ref":"eurostat:htec_emp_nat2","field_path":"hightech_employment_pct.2024","value":4.2,"unit":"% of total employment","quality_flag":"proxy","note":"Adverse scenario: high-tech employment contracting (proxy estimate)"},{"indicator_id":"rd_personnel_pct","source_type":"eurostat","source_ref":"eurostat:rd_p_persocc","field_path":"rd_personnel_pct.2024","value":1.28,"unit":"% active population","quality_flag":"proxy","note":"Adverse scenario: contraction in R&D headcount (proxy estimate)"}]} \ No newline at end of file diff --git a/modules/strategy-agent/examples/assess_response_baseline.json b/modules/strategy-agent/examples/assess_response_baseline.json new file mode 100644 index 0000000..695e492 --- /dev/null +++ b/modules/strategy-agent/examples/assess_response_baseline.json @@ -0,0 +1 @@ +{"run_id":"quantum-act-001","scenario_id":"baseline_v1","option_scores":[{"option_id":"opt-3","option_name":"Comprehensive regulation — EU Quantum Agency","criteria_scores":[{"criterion":"economic","score":2.0,"rationale":"Dedicated agency and procurement rules expected to significantly increase GERD above 2.24% GDP in quantum-specific sectors.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":2.0,"rationale":"Mandatory training and certification boosts skilled employment; R&D personnel ratio (1.43%) projected to rise.","evidence_ids":["rd_personnel_pct","hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Comprehensive framework includes sustainability criteria for quantum hardware.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Strongest intervention; 320 quantum projects brought under single governance with mandatory certification and EU procurement preference.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":-1.0,"rationale":"High implementation complexity; agency setup requires 2-4 years and significant institutional coordination.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Coherent with EU strategic autonomy agenda but creates new regulatory layer.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.35,"rank":1,"status":"preferred"},{"option_id":"opt-2","option_name":"Light regulation — standardisation and certification","criteria_scores":[{"criterion":"economic","score":1.0,"rationale":"Standardisation reduces fragmentation costs; builds on 1200M EUR quantum investment base.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":1.0,"rationale":"Certification requirements create skilled workforce demand; high-tech employment at 4.8% provides labour base.","evidence_ids":["hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Energy-efficiency standards for quantum hardware reduce footprint.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Mandatory standards position EU as global norm-setter in quantum, leveraging 320 active projects.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":1.0,"rationale":"Manageable compliance burden; existing ETSI/CEN structures can absorb.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":2.0,"rationale":"Fully coherent with NIS2, Cyber Resilience Act, and Digital Decade targets.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.35,"rank":2,"status":"alternative"},{"option_id":"opt-1","option_name":"Coordination only — voluntary frameworks","criteria_scores":[{"criterion":"economic","score":1.0,"rationale":"Soft coordination expected to modestly increase GERD above 2.24% GDP via improved funding alignment.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":1.0,"rationale":"Voluntary talent pipelines could raise R&D personnel (currently 1.43% active pop) through mobility schemes.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"Coordination option has negligible direct environmental impact.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":1.0,"rationale":"Aligning 320 existing CORDIS quantum projects under a voluntary framework improves visibility but lacks enforcement.","evidence_ids":["cordis_quantum_projects"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Voluntary framework is low-risk and fast to implement.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Consistent with existing EU open-coordination method.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.05,"rank":3,"status":"alternative"},{"option_id":"opt-0","option_name":"Baseline — no new intervention","criteria_scores":[{"criterion":"economic","score":0.0,"rationale":"No new spending; GERD at 2.24% GDP continues on current trajectory.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":0.0,"rationale":"R&D personnel at 1.43% active pop; no structural change expected.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"No regulatory change; environmental impact remains as-is.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":-1.0,"rationale":"With 320 quantum projects and 1200M EUR funded, absence of dedicated framework risks falling behind US/China.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Baseline requires no implementation effort.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":0.0,"rationale":"Consistent with existing EU policy but misses Quantum Flagship ambition.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":0.05,"rank":4,"status":"baseline"}],"drivers":[{"criterion":"economic","direction":"positive","contribution":0.5,"source_ref":"gerd_pct_gdp, cordis_quantum_funding_meur"},{"criterion":"competitiveness","direction":"positive","contribution":0.5,"source_ref":"cordis_quantum_projects, cordis_quantum_funding_meur"},{"criterion":"social","direction":"positive","contribution":0.3,"source_ref":"rd_personnel_pct, hightech_employment_pct"}],"assumptions":["[Comprehensive regulation — EU Quantum Agency / environmental] No direct indicator available; score based on domain knowledge."],"evidence":[{"indicator_id":"cordis_quantum_funding_meur","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_funding_meur.latest","value":1200.0,"unit":"M EUR","quality_flag":"assumption","note":"CORDIS fetch failed; assumption based on Quantum Flagship budget"},{"indicator_id":"cordis_quantum_projects","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_projects.latest","value":320.0,"unit":"projects","quality_flag":"proxy","note":"CORDIS fetch failed; using 2024 approximate count as proxy"},{"indicator_id":"gerd_pct_gdp","source_type":"eurostat","source_ref":"eurostat:rd_e_gerdtot","field_path":"gerd_pct_gdp.latest","value":2.24,"unit":"% of GDP","quality_flag":"verified","note":"Total R&D expenditure, EU27_2020, latest available year"},{"indicator_id":"hightech_employment_pct","source_type":"eurostat","source_ref":"eurostat:htec_emp_nat","field_path":"hightech_employment_pct.latest","value":4.8,"unit":"% of total employment","quality_flag":"proxy","note":"Eurostat fetch failed; using 2022 published value as proxy"},{"indicator_id":"rd_personnel_pct","source_type":"eurostat","source_ref":"eurostat:rd_p_persocc","field_path":"rd_personnel_pct.latest","value":1.43,"unit":"% active population","quality_flag":"proxy","note":"Eurostat fetch failed; using 2022 published value as proxy"}]} \ No newline at end of file diff --git a/modules/strategy-agent/examples/assess_response_recovery.json b/modules/strategy-agent/examples/assess_response_recovery.json new file mode 100644 index 0000000..af595bc --- /dev/null +++ b/modules/strategy-agent/examples/assess_response_recovery.json @@ -0,0 +1 @@ +{"run_id":"quantum-act-003","scenario_id":"recovery_v1","option_scores":[{"option_id":"opt-2","option_name":"Light regulation — standardisation and certification","criteria_scores":[{"criterion":"economic","score":2.0,"rationale":"Standardisation unlocks full value of growing 1650M EUR quantum investment base; GERD at 2.45% provides strong fiscal foundation.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":2.0,"rationale":"Certification requirements create skilled workforce demand; strong high-tech employment at 5.3% provides deep labour base.","evidence_ids":["hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Energy-efficiency standards for quantum hardware reduce footprint.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Mandatory standards position EU as global norm-setter in quantum, leveraging 410 active projects.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":1.0,"rationale":"Manageable compliance burden; existing ETSI/CEN structures can absorb.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":2.0,"rationale":"Fully coherent with NIS2, Cyber Resilience Act, and Digital Decade targets.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.75,"rank":1,"status":"preferred"},{"option_id":"opt-3","option_name":"Comprehensive regulation — EU Quantum Agency","criteria_scores":[{"criterion":"economic","score":2.0,"rationale":"Dedicated agency and procurement rules expected to significantly increase GERD above 2.45% GDP in quantum-specific sectors.","evidence_ids":["gerd_pct_gdp","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"social","score":2.0,"rationale":"Mandatory training and certification boosts skilled employment; R&D personnel ratio (1.62%) projected to rise.","evidence_ids":["rd_personnel_pct","hightech_employment_pct"],"assumption":null},{"criterion":"environmental","score":1.0,"rationale":"Comprehensive framework includes sustainability criteria for quantum hardware.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":2.0,"rationale":"Strongest intervention; 410 quantum projects brought under single governance with mandatory certification and EU procurement preference.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":-1.0,"rationale":"High implementation complexity; agency setup requires 2-4 years and significant institutional coordination.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Coherent with EU strategic autonomy agenda but creates new regulatory layer.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.35,"rank":2,"status":"alternative"},{"option_id":"opt-1","option_name":"Coordination only — voluntary frameworks","criteria_scores":[{"criterion":"economic","score":1.0,"rationale":"Soft coordination expected to modestly increase GERD above 2.45% GDP via improved funding alignment.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":1.0,"rationale":"Voluntary talent pipelines could raise R&D personnel (currently 1.62% active pop) through mobility schemes.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"Coordination option has negligible direct environmental impact.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":1.0,"rationale":"Aligning 410 existing CORDIS quantum projects under a voluntary framework improves visibility but lacks enforcement.","evidence_ids":["cordis_quantum_projects"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Voluntary framework is low-risk and fast to implement.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":1.0,"rationale":"Consistent with existing EU open-coordination method.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":1.05,"rank":3,"status":"alternative"},{"option_id":"opt-0","option_name":"Baseline — no new intervention","criteria_scores":[{"criterion":"economic","score":0.0,"rationale":"No new spending; GERD at 2.45% GDP continues on current trajectory.","evidence_ids":["gerd_pct_gdp"],"assumption":null},{"criterion":"social","score":0.0,"rationale":"R&D personnel at 1.62% active pop; no structural change expected.","evidence_ids":["rd_personnel_pct"],"assumption":null},{"criterion":"environmental","score":0.0,"rationale":"No regulatory change; environmental impact remains as-is.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"competitiveness","score":-1.0,"rationale":"With 410 quantum projects and 1650M EUR funded, absence of dedicated framework risks falling behind US/China.","evidence_ids":["cordis_quantum_projects","cordis_quantum_funding_meur"],"assumption":null},{"criterion":"feasibility","score":2.0,"rationale":"Baseline requires no implementation effort.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."},{"criterion":"coherence","score":0.0,"rationale":"Consistent with existing EU policy but misses Quantum Flagship ambition.","evidence_ids":[],"assumption":"No direct indicator available; score based on domain knowledge."}],"weighted_total":0.05,"rank":4,"status":"baseline"}],"drivers":[{"criterion":"economic","direction":"positive","contribution":0.5,"source_ref":"gerd_pct_gdp, cordis_quantum_funding_meur"},{"criterion":"competitiveness","direction":"positive","contribution":0.5,"source_ref":"cordis_quantum_projects, cordis_quantum_funding_meur"},{"criterion":"social","direction":"positive","contribution":0.3,"source_ref":"hightech_employment_pct"}],"assumptions":["[Light regulation — standardisation and certification / environmental] No direct indicator available; score based on domain knowledge."],"evidence":[{"indicator_id":"cordis_quantum_funding_meur","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_funding_meur.2025","value":1650.0,"unit":"M EUR","quality_flag":"proxy","note":"Recovery scenario: increased EC quantum budget allocation (proxy estimate)"},{"indicator_id":"cordis_quantum_projects","source_type":"cordis","source_ref":"cordis:search?q=quantum","field_path":"cordis_quantum_projects.2025","value":410.0,"unit":"projects","quality_flag":"proxy","note":"Recovery scenario: oversubscribed Horizon Europe quantum calls (proxy estimate)"},{"indicator_id":"gerd_pct_gdp","source_type":"eurostat","source_ref":"eurostat:rd_e_gerdtot","field_path":"gerd_pct_gdp.2025","value":2.45,"unit":"% of GDP","quality_flag":"proxy","note":"Recovery scenario: GERD recovering above 3% target trajectory (proxy estimate)"},{"indicator_id":"hightech_employment_pct","source_type":"eurostat","source_ref":"eurostat:htec_emp_nat2","field_path":"hightech_employment_pct.2025","value":5.3,"unit":"% of total employment","quality_flag":"proxy","note":"Recovery scenario: high-tech employment growing above trend (proxy estimate)"},{"indicator_id":"rd_personnel_pct","source_type":"eurostat","source_ref":"eurostat:rd_p_persocc","field_path":"rd_personnel_pct.2025","value":1.62,"unit":"% active population","quality_flag":"proxy","note":"Recovery scenario: R&D headcount expanding with new Horizon Europe cohort (proxy estimate)"}]} \ No newline at end of file diff --git a/modules/strategy-agent/requirements.txt b/modules/strategy-agent/requirements.txt new file mode 100644 index 0000000..8486619 --- /dev/null +++ b/modules/strategy-agent/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.8.2 +httpx==0.27.0 +anthropic==0.49.0 +reportlab==4.2.2 \ No newline at end of file diff --git a/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212716.png b/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212716.png new file mode 100644 index 0000000..3e0a6ab Binary files /dev/null and b/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212716.png differ diff --git a/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212818.png b/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212818.png new file mode 100644 index 0000000..5652633 Binary files /dev/null and b/modules/strategy-agent/screenshots_capstone/Captura de pantalla 2026-03-31 212818.png differ diff --git a/modules/strategy-agent/screenshots_capstone/MCDA_Baseline.png b/modules/strategy-agent/screenshots_capstone/MCDA_Baseline.png new file mode 100644 index 0000000..f07f6d6 Binary files /dev/null and b/modules/strategy-agent/screenshots_capstone/MCDA_Baseline.png differ diff --git a/modules/strategy-agent/screenshots_capstone/Sensitivity_baseline.png b/modules/strategy-agent/screenshots_capstone/Sensitivity_baseline.png new file mode 100644 index 0000000..9538e5e Binary files /dev/null and b/modules/strategy-agent/screenshots_capstone/Sensitivity_baseline.png differ diff --git a/modules/strategy-agent/streamlit_app.py b/modules/strategy-agent/streamlit_app.py new file mode 100644 index 0000000..61c39ed --- /dev/null +++ b/modules/strategy-agent/streamlit_app.py @@ -0,0 +1,421 @@ +""" +streamlit_app.py — Strategy & Feedback Agent UI +Calls the live SFA service at localhost:8010. +Run with: python -m streamlit run modules/strategy-agent/streamlit_app.py +""" + +import streamlit as st +import requests +import json +import pandas as pd + +API_BASE = "http://localhost:8010" + +st.set_page_config( + page_title="EU Impact Assessment — Strategy & Feedback Agent", + page_icon="🇪🇺", + layout="wide", +) + +# ─── Header ─────────────────────────────────────────────────────────────────── + +st.markdown(""" +
+ Reusable, evidence-grounded IA artefacts · OpenPolicyStack · v0.3.0 +
+