|
| 1 | +"""Phase 7 — refresh targets generation. |
| 2 | +
|
| 3 | +Pure extraction + rendering for <slug>/refresh_targets.md (the entry point for a |
| 4 | +future `update <slug>` delta-research run). No network, no I/O — the orchestrator |
| 5 | +reads RunState and writes the file. Mirrors scoring.py / verify.py. |
| 6 | +""" |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import re |
| 10 | +from urllib.parse import urlsplit |
| 11 | + |
| 12 | +try: |
| 13 | + from .scoring import hypothesis_ids |
| 14 | +except ImportError: # run as a script |
| 15 | + from scoring import hypothesis_ids |
| 16 | + |
| 17 | +DATA_DOMAINS = ("worldbank", "statista", "oecd", "data.gov", "stlouisfed") |
| 18 | + |
| 19 | +_TODO_ENTITY = ("<!-- TODO: pricing/careers/crunchbase split + sha256 hash" |
| 20 | + " — требуют M2/M5 block-render -->") |
| 21 | +_TODO_NUMBER = ("<!-- TODO: series id + last_value + API access" |
| 22 | + " — требуют N-block render -->") |
| 23 | +_TODO_TOPIC = ("<!-- TODO: OpenAlex concept IDs / GitHub topics / news keywords" |
| 24 | + " — требуют Phase 4 discovery-метаданных в RunState -->") |
| 25 | + |
| 26 | + |
| 27 | +def extract_hypotheses(hypotheses: list[str], triangulation: list[dict]) -> list[dict]: |
| 28 | + """Pair each hypothesis with its triangulation status. |
| 29 | +
|
| 30 | + supported = has supporting types and not under_triangulated |
| 31 | + inconclusive = under_triangulated, or no triangulation record |
| 32 | + """ |
| 33 | + by_id = {row.get("id"): row for row in triangulation} |
| 34 | + ids = hypothesis_ids(hypotheses) |
| 35 | + out = [] |
| 36 | + for hid, raw in zip(ids, hypotheses): |
| 37 | + text = raw.split(":", 1)[1].strip() if ":" in raw else raw.strip() |
| 38 | + row = by_id.get(hid) |
| 39 | + n_sup = row.get("distinct_types_supporting", 0) if row else 0 |
| 40 | + under = row.get("under_triangulated", True) if row else True |
| 41 | + status = "supported" if (n_sup > 0 and not under) else "inconclusive" |
| 42 | + out.append({"id": hid, "text": text, "status": status, |
| 43 | + "supporting_types": n_sup}) |
| 44 | + return out |
| 45 | + |
| 46 | + |
| 47 | +def extract_entities(sources: list[dict]) -> list[dict]: |
| 48 | + """One entity per distinct URL domain, first source wins. Skips empty URLs.""" |
| 49 | + seen: set[str] = set() |
| 50 | + out = [] |
| 51 | + for src in sources: |
| 52 | + url = (src.get("url") or "").strip() |
| 53 | + if not url: |
| 54 | + continue |
| 55 | + domain = urlsplit(url).netloc |
| 56 | + if not domain or domain in seen: |
| 57 | + continue |
| 58 | + seen.add(domain) |
| 59 | + out.append({"domain": domain, "url": url, |
| 60 | + "why": (src.get("claim") or "").strip()}) |
| 61 | + return out |
| 62 | + |
| 63 | + |
| 64 | +def extract_numbers(sources: list[dict]) -> list[dict]: |
| 65 | + """Sources whose claim contains a digit, or whose URL is a known data domain.""" |
| 66 | + out = [] |
| 67 | + for src in sources: |
| 68 | + claim = (src.get("claim") or "").strip() |
| 69 | + url = (src.get("url") or "").strip() |
| 70 | + host = urlsplit(url).netloc.lower() |
| 71 | + is_data_domain = any(d in host for d in DATA_DOMAINS) |
| 72 | + if not (re.search(r"\d", claim) or is_data_domain): |
| 73 | + continue |
| 74 | + out.append({"phrase": claim or url, "url": url}) |
| 75 | + return out |
| 76 | + |
| 77 | + |
| 78 | +def extract_carry_forward(deviations_text: str) -> list[dict]: |
| 79 | + """Parse deviations.md: each '## D*' block with a carry_forward line becomes a |
| 80 | + refresh candidate. subquestion defaults to '?' if the block lacks one.""" |
| 81 | + out = [] |
| 82 | + # blocks[0] is the file preamble (before the first "## D" header) — skip it |
| 83 | + # so a stray carry_forward line outside any deviation block isn't captured. |
| 84 | + blocks = re.split(r"^## D\d+\b.*$", deviations_text, flags=re.MULTILINE) |
| 85 | + for block in blocks[1:]: |
| 86 | + cf = re.search(r"^- carry_forward:\s*(.+)$", block, flags=re.MULTILINE) |
| 87 | + if not cf: |
| 88 | + continue |
| 89 | + sq = re.search(r"^- subquestion:\s*(.+)$", block, flags=re.MULTILINE) |
| 90 | + subq = sq.group(1).strip() if sq else "?" |
| 91 | + out.append({"subquestion": subq, "carry_forward": cf.group(1).strip()}) |
| 92 | + return out |
| 93 | + |
| 94 | + |
| 95 | +def render_refresh_targets(slug: str, depth: str, hypotheses: list[dict], |
| 96 | + entities: list[dict], numbers: list[dict], |
| 97 | + carry: list[dict], *, today: str) -> str: |
| 98 | + """Render <slug>/refresh_targets.md per the Z11 template. Pure: `today` is |
| 99 | + passed in so the output is deterministic in tests.""" |
| 100 | + cadence = "30 days" if depth == "deep" else "90 days" |
| 101 | + out = [ |
| 102 | + "---", |
| 103 | + f"slug: {slug}", |
| 104 | + f"last_research_date: {today}", |
| 105 | + f"depth: {depth}", |
| 106 | + f"update_cadence: {cadence}", |
| 107 | + "---", |
| 108 | + "", |
| 109 | + f"# Refresh targets — {slug}", |
| 110 | + "", |
| 111 | + "## 1. Entities to track", |
| 112 | + ] |
| 113 | + if entities: |
| 114 | + for e in entities: |
| 115 | + out += [f"### {e['domain']}", |
| 116 | + f"- **Source URL:** {e['url']}", |
| 117 | + f"- **Why in scope:** {e['why'] or '—'}", ""] |
| 118 | + else: |
| 119 | + out += ["_none_", ""] |
| 120 | + out.append(_TODO_ENTITY) |
| 121 | + |
| 122 | + out += ["", "## 2. Numbers to refresh"] |
| 123 | + if numbers: |
| 124 | + for n in numbers: |
| 125 | + out += [f"### {n['phrase']}", f"- **Source:** {n['url'] or '—'}", ""] |
| 126 | + else: |
| 127 | + out += ["_none_", ""] |
| 128 | + out.append(_TODO_NUMBER) |
| 129 | + |
| 130 | + out += ["", "## 3. Topic markers (discovery)", _TODO_TOPIC] |
| 131 | + |
| 132 | + out += ["", "## 4. Hypotheses to re-test"] |
| 133 | + if hypotheses: |
| 134 | + for h in hypotheses: |
| 135 | + out += [ |
| 136 | + f'### {h["id"]}: "{h["text"]}"', |
| 137 | + f"- **Status at last research:** {h['status']}", |
| 138 | + f"- **Supporting source types:** {h['supporting_types']}", |
| 139 | + f'- **Watch for:** "{h["text"]} failed replication"; ' |
| 140 | + "retractions (RetractionWatch); counter-evidence", ""] |
| 141 | + else: |
| 142 | + out += ["_no hypotheses recorded_", ""] |
| 143 | + |
| 144 | + if out and out[-1] == "": |
| 145 | + out.pop() |
| 146 | + out += ["", "## 5. Refresh candidates (carry-forward)"] |
| 147 | + if carry: |
| 148 | + for c in carry: |
| 149 | + out.append(f"- **{c['subquestion']}** — {c['carry_forward']}") |
| 150 | + else: |
| 151 | + out.append("_none_") |
| 152 | + out.append("") |
| 153 | + return "\n".join(out) |
0 commit comments