| 1 | import json |
| 2 | from pathlib import Path |
| 3 | |
| 4 | import last30days as cli |
| 5 | from lib import health, schema |
| 6 | |
| 7 | |
| 8 | GOLDEN = Path(__file__).parent / "fixtures" / "agent_export_v1.json" |
| 9 | |
| 10 | |
| 11 | def _report() -> schema.Report: |
| 12 | reddit_item = schema.SourceItem( |
| 13 | item_id="reddit-1", |
| 14 | source="reddit", |
| 15 | title="Agents move into daily coding workflows", |
| 16 | body="Developers described where coding agents save time.", |
| 17 | url="https://www.reddit.com/r/programming/comments/agent-workflows", |
| 18 | published_at="2026-06-28", |
| 19 | snippet="Developers shared concrete agent workflows.", |
| 20 | engagement={"score": 1543, "num_comments": 201}, |
| 21 | ) |
| 22 | x_item = schema.SourceItem( |
| 23 | item_id="x-1", |
| 24 | source="x", |
| 25 | title="Teams compare coding-agent review loops", |
| 26 | body="A thread compared review loops across several tools.", |
| 27 | url="https://x.com/example/status/123", |
| 28 | published_at="2026-07-02", |
| 29 | snippet="Teams compared how agents fit into code review.", |
| 30 | engagement={"likes": 800, "reposts": 50}, |
| 31 | ) |
| 32 | digg_item = schema.SourceItem( |
| 33 | item_id="digg-1", |
| 34 | source="digg", |
| 35 | title="Agents climb the Digg AI leaderboard", |
| 36 | body="A Digg cluster collected five posts from four authors.", |
| 37 | url="https://di.gg/ai/agent-leaderboard", |
| 38 | published_at="2026-07-05", |
| 39 | snippet="A small Digg cluster appeared low on the leaderboard.", |
| 40 | engagement={"postCount": 5, "uniqueAuthors": 4, "rank": 500, "rank_score": 0.0}, |
| 41 | ) |
| 42 | reddit_candidate = schema.Candidate( |
| 43 | candidate_id="candidate-reddit", |
| 44 | item_id=reddit_item.item_id, |
| 45 | source="reddit", |
| 46 | title=reddit_item.title, |
| 47 | url=reddit_item.url, |
| 48 | snippet=reddit_item.snippet, |
| 49 | subquery_labels=["workflows"], |
| 50 | native_ranks={"workflows:reddit": 1}, |
| 51 | local_relevance=0.95, |
| 52 | freshness=85, |
| 53 | engagement=100, |
| 54 | source_quality=0.6, |
| 55 | rrf_score=0.02, |
| 56 | final_score=92, |
| 57 | cluster_id="cluster-workflows", |
| 58 | source_items=[reddit_item], |
| 59 | ) |
| 60 | x_candidate = schema.Candidate( |
| 61 | candidate_id="candidate-x", |
| 62 | item_id=x_item.item_id, |
| 63 | source="x", |
| 64 | title=x_item.title, |
| 65 | url=x_item.url, |
| 66 | snippet=x_item.snippet, |
| 67 | subquery_labels=["reviews"], |
| 68 | native_ranks={"reviews:x": 1}, |
| 69 | local_relevance=0.88, |
| 70 | freshness=92, |
| 71 | engagement=80, |
| 72 | source_quality=0.68, |
| 73 | rrf_score=0.018, |
| 74 | final_score=84, |
| 75 | source_items=[x_item], |
| 76 | ) |
| 77 | digg_candidate = schema.Candidate( |
| 78 | candidate_id="candidate-digg", |
| 79 | item_id=digg_item.item_id, |
| 80 | source="digg", |
| 81 | title=digg_item.title, |
| 82 | url=digg_item.url, |
| 83 | snippet=digg_item.snippet, |
| 84 | subquery_labels=["leaderboard"], |
| 85 | native_ranks={"leaderboard:digg": 500}, |
| 86 | local_relevance=0.78, |
| 87 | freshness=75, |
| 88 | engagement=5, |
| 89 | source_quality=0.6, |
| 90 | rrf_score=0.01, |
| 91 | final_score=70, |
| 92 | cluster_id="cluster-digg", |
| 93 | source_items=[digg_item], |
| 94 | ) |
| 95 | return schema.Report( |
| 96 | topic="AI coding agents", |
| 97 | range_from="2026-06-10", |
| 98 | range_to="2026-07-10", |
| 99 | generated_at="2026-07-10T00:00:00+00:00", |
| 100 | provider_runtime=schema.ProviderRuntime( |
| 101 | reasoning_provider="local", |
| 102 | planner_model="fixture-planner", |
| 103 | rerank_model="fixture-reranker", |
| 104 | ), |
| 105 | query_plan=schema.QueryPlan( |
| 106 | intent="research", |
| 107 | freshness_mode="strict_recent", |
| 108 | cluster_mode="story", |
| 109 | raw_topic="AI coding agents", |
| 110 | subqueries=[ |
| 111 | schema.SubQuery( |
| 112 | label="workflows", |
| 113 | search_query="AI coding agent workflows", |
| 114 | ranking_query="How are developers using AI coding agents?", |
| 115 | sources=["reddit"], |
| 116 | ) |
| 117 | ], |
| 118 | source_weights={"reddit": 1.0, "x": 0.8}, |
| 119 | ), |
| 120 | clusters=[ |
| 121 | schema.Cluster( |
| 122 | cluster_id="cluster-workflows", |
| 123 | title="Agents move into daily coding workflows", |
| 124 | candidate_ids=[reddit_candidate.candidate_id], |
| 125 | representative_ids=[reddit_candidate.candidate_id], |
| 126 | sources=["reddit"], |
| 127 | score=92, |
| 128 | ), |
| 129 | schema.Cluster( |
| 130 | cluster_id="cluster-reviews", |
| 131 | title="Teams compare coding-agent review loops", |
| 132 | candidate_ids=[x_candidate.candidate_id], |
| 133 | representative_ids=[x_candidate.candidate_id], |
| 134 | sources=["x"], |
| 135 | score=84, |
| 136 | ), |
| 137 | schema.Cluster( |
| 138 | cluster_id="cluster-digg", |
| 139 | title="Agents climb the Digg AI leaderboard", |
| 140 | candidate_ids=[digg_candidate.candidate_id], |
| 141 | representative_ids=[digg_candidate.candidate_id], |
| 142 | sources=["digg"], |
| 143 | score=70, |
| 144 | ), |
| 145 | ], |
| 146 | ranked_candidates=[reddit_candidate, x_candidate, digg_candidate], |
| 147 | items_by_source={"reddit": [reddit_item], "x": [x_item], "digg": [digg_item]}, |
| 148 | errors_by_source={ |
| 149 | "youtube": "HTTP 429", |
| 150 | "github": "HTTP 401", |
| 151 | "grounding": "DNS failure", |
| 152 | }, |
| 153 | source_status={ |
| 154 | "reddit": schema.SourceOutcome(source="reddit", state=health.OK, items_returned=1), |
| 155 | "x": schema.SourceOutcome(source="x", state=health.OK, items_returned=1), |
| 156 | "digg": schema.SourceOutcome(source="digg", state=health.OK, items_returned=1), |
| 157 | "hackernews": schema.SourceOutcome(source="hackernews", state=schema.NO_RESULTS), |
| 158 | "youtube": schema.SourceOutcome(source="youtube", state=schema.RATE_LIMITED), |
| 159 | "grounding": schema.SourceOutcome(source="grounding", state=schema.UNREACHABLE), |
| 160 | "github": schema.SourceOutcome(source="github", state=schema.AUTH_FAILED), |
| 161 | }, |
| 162 | ) |
| 163 | |
| 164 | |
| 165 | def test_agent_export_matches_v1_2_golden_contract(): |
| 166 | expected = json.loads(GOLDEN.read_text(encoding="utf-8")) |
| 167 | |
| 168 | assert schema.to_agent_export(_report()) == expected |
| 169 | |
| 170 | |
| 171 | def test_agent_export_maps_per_run_source_outcomes_to_states(): |
| 172 | exported = schema.to_agent_export(_report()) |
| 173 | |
| 174 | assert exported["source_status"] == { |
| 175 | "digg": "ok", |
| 176 | "github": "auth-failed", |
| 177 | "grounding": "unreachable", |
| 178 | "hackernews": "no-results", |
| 179 | "reddit": "ok", |
| 180 | "x": "ok", |
| 181 | "youtube": "rate-limited", |
| 182 | } |
| 183 | |
| 184 | |
| 185 | def test_agent_export_uses_digg_post_count_not_rank_for_cluster_engagement(): |
| 186 | exported = schema.to_agent_export(_report()) |
| 187 | |
| 188 | assert exported["clusters"][2]["engagement_total"] == 5 |
| 189 | |
| 190 | |
| 191 | def test_agent_export_excludes_non_counter_metadata_from_cluster_engagement(): |
| 192 | report = _report() |
| 193 | report.ranked_candidates[0].source = "web" |
| 194 | report.ranked_candidates[0].source_items[0].source = "web" |
| 195 | report.ranked_candidates[0].source_items[0].engagement = { |
| 196 | "views": 5, |
| 197 | "rank": 500, |
| 198 | "rank_score": 400, |
| 199 | "ranking_score": 300, |
| 200 | "score": 200, |
| 201 | "upvote_ratio": 0.95, |
| 202 | "rating": 4.9, |
| 203 | "trustScore": 3.4, |
| 204 | } |
| 205 | |
| 206 | exported = schema.to_agent_export(report) |
| 207 | |
| 208 | assert exported["clusters"][0]["engagement_total"] == 5 |
| 209 | |
| 210 | |
| 211 | def test_raw_profile_is_byte_identical_to_legacy_report_dump(): |
| 212 | report = _report() |
| 213 | legacy = json.dumps(schema.to_dict(report), indent=2, sort_keys=True) |
| 214 | |
| 215 | assert cli.emit_output(report, "json", json_profile="raw") == legacy |
| 216 | |
| 217 | |
| 218 | def test_raw_comparison_profile_is_byte_identical_to_legacy_wrapper(): |
| 219 | report = _report() |
| 220 | reports = [("AI coding agents", report)] |
| 221 | legacy = json.dumps( |
| 222 | { |
| 223 | "comparison": True, |
| 224 | "entities": ["AI coding agents"], |
| 225 | "reports": [{"entity": "AI coding agents", "report": schema.to_dict(report)}], |
| 226 | }, |
| 227 | indent=2, |
| 228 | sort_keys=True, |
| 229 | ) |
| 230 | |
| 231 | assert cli.emit_comparison_output(reports, "json", json_profile="raw") == legacy |
| 232 | |
| 233 | |
| 234 | def test_json_profile_parser_defaults_to_agent_and_accepts_raw(): |
| 235 | parser = cli.build_parser() |
| 236 | |
| 237 | assert parser.parse_args(["topic", "--emit=json"]).json_profile == "agent" |
| 238 | assert parser.parse_args(["topic", "--emit=json", "--json-profile=raw"]).json_profile == "raw" |
| 239 | |
| 240 | |
| 241 | def _reach_candidate(source, engagement): |
| 242 | item = schema.SourceItem( |
| 243 | item_id=f"{source}-reach-1", |
| 244 | source=source, |
| 245 | title="reach test", |
| 246 | body="reach test body", |
| 247 | url=f"https://example.com/{source}/reach", |
| 248 | published_at="2026-07-05", |
| 249 | snippet="reach test snippet", |
| 250 | engagement=engagement, |
| 251 | ) |
| 252 | return schema.Candidate( |
| 253 | candidate_id=f"candidate-{source}-reach", |
| 254 | item_id=item.item_id, |
| 255 | source=source, |
| 256 | title=item.title, |
| 257 | url=item.url, |
| 258 | snippet=item.snippet, |
| 259 | subquery_labels=["primary"], |
| 260 | native_ranks={f"primary:{source}": 1}, |
| 261 | local_relevance=0.5, |
| 262 | freshness=50, |
| 263 | engagement=10, |
| 264 | source_quality=0.5, |
| 265 | rrf_score=0.01, |
| 266 | final_score=50, |
| 267 | cluster_id="cluster-reach", |
| 268 | source_items=[item], |
| 269 | ) |
| 270 | |
| 271 | |
| 272 | def test_headline_engagement_excludes_author_reach_for_stocktwits(): |
| 273 | candidate = _reach_candidate( |
| 274 | "stocktwits", {"likes": 12, "reshares": 3, "followers": 250000} |
| 275 | ) |
| 276 | assert schema._headline_engagement(candidate) == 12.0 |
| 277 | |
| 278 | |
| 279 | def test_headline_engagement_excludes_followers_generically(): |
| 280 | candidate = _reach_candidate( |
| 281 | "linkedin", {"reactions": 40, "followers": 90000} |
| 282 | ) |
| 283 | assert schema._headline_engagement(candidate) == 40.0 |
| 284 |