Skip to content

Commit 9f03880

Browse files
Merge pull request #5 from Socialpranker/feat/phase7-refresh
feat: Phase 7 — Refresh targets generation
2 parents 865268a + 41a69ae commit 9f03880

5 files changed

Lines changed: 395 additions & 2 deletions

File tree

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
[pytest]
2+
addopts = -m "not live"
23
markers =
34
live: hits real LLM APIs; needs API keys; skipped by default (run with -m live)

runner/orchestrator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@
5555
except ImportError: # run as a script
5656
from verify import PLACEHOLDER, render_verification
5757

58+
try:
59+
from .refresh import (extract_carry_forward, extract_entities,
60+
extract_hypotheses, extract_numbers,
61+
render_refresh_targets)
62+
except ImportError: # run as a script
63+
from refresh import (extract_carry_forward, extract_entities,
64+
extract_hypotheses, extract_numbers,
65+
render_refresh_targets)
66+
5867
DEPTH_SOURCES = {"shallow": 6, "medium": 14, "deep": 28}
5968
DEPTH_FANOUT = {"shallow": 0, "medium": 3, "deep": 5}
6069
GENRE_BY_HINT = [
@@ -336,6 +345,24 @@ def verify(self, s: RunState) -> None:
336345
text = block + "\n"
337346
report_path.write_text(text, encoding="utf-8")
338347

348+
# --- Phase 7: refresh targets generation -------------------------------
349+
def refresh(self, s: RunState) -> None:
350+
if s.depth == "shallow":
351+
return # refresh targets are for medium/deep only
352+
try:
353+
devs_text = (s.dir / "deviations.md").read_text(encoding="utf-8")
354+
except (FileNotFoundError, OSError):
355+
devs_text = ""
356+
content = render_refresh_targets(
357+
s.slug, s.depth,
358+
extract_hypotheses(s.hypotheses, s.triangulation),
359+
extract_entities(s.sources),
360+
extract_numbers(s.sources),
361+
extract_carry_forward(devs_text),
362+
today=dt.date.today().isoformat(),
363+
)
364+
(s.dir / "refresh_targets.md").write_text(content, encoding="utf-8")
365+
339366
def run(self, question: str, depth: str, root: Path) -> Path:
340367
s = RunState(question=question, depth=depth, root=root)
341368
self.reframe(s)
@@ -347,6 +374,7 @@ def run(self, question: str, depth: str, root: Path) -> Path:
347374
self.score(s)
348375
self.synthesize(s)
349376
self.verify(s)
377+
self.refresh(s)
350378
return s.dir
351379

352380

runner/providers.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import os
1919
from typing import Callable, Protocol, runtime_checkable
2020

21+
try:
22+
from .scoring import hypothesis_ids
23+
except ImportError: # run as a script
24+
from scoring import hypothesis_ids
25+
2126
TIERS = ("strong", "mid", "cheap")
2227
SEARCH_TRIGGERS = ("empty_result", "citation_lead", "unexpected_finding", "contradiction")
2328

@@ -183,7 +188,6 @@ def search(self, subquery: str, *, subquestion_id: str = "Q0", model_tier: str =
183188
def score(self, sources: list[dict], hypotheses: list[str],
184189
*, model_tier: str = "cheap") -> dict:
185190
assert model_tier in TIERS, f"unknown tier {model_tier}"
186-
from runner.scoring import hypothesis_ids
187191
hids = hypothesis_ids(hypotheses)
188192
scored = []
189193
for src in sources:
@@ -338,7 +342,6 @@ def search(self, subquery: str, *, subquestion_id: str = "Q0", model_tier: str =
338342

339343
def score(self, sources: list[dict], hypotheses: list[str],
340344
*, model_tier: str = "cheap") -> dict:
341-
from runner.scoring import hypothesis_ids
342345
hids = hypothesis_ids(hypotheses)
343346
ev_props = {hid: {"type": "string", "enum": list(STANCES)} for hid in hids}
344347
score_item = {

runner/refresh.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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

Comments
 (0)